Author SHA1 Message Date
aj 761cee7c1f feat(cutting): plan verified part order from fixed programs 2026-10-02 21:56:14 -04:00
aj c49c387af5 merge: integrate exact-contact rotated rectangle repair
Brings in the independently reviewed exact-contact repair (c7b4ba4,
25755a8, bd5db59): line-only part outlines keep exact analytic bounds,
so an 8x3 part fits rotated on 8.5x3.5 stock at 0.25 spacing, while
curved outlines keep their conservative padding and no validator,
spacing or fit tolerance changes. The held hole-profile and pocket
commits built on bd5db59 stay unmerged.
2026-10-02 20:50:19 -04:00
aj 9e15d5dceb fix(desktop): track only the latest database browse request
A request is now marked superseded before the previous one is
cancelled, so a query that completes synchronously on cancellation
returns quietly instead of clearing the page and surfacing a stale
cancellation error. NestBrowseSession.IsLoading follows only the latest
request, and the dialog's Previous/Next and Loading text use it rather
than a count that included superseded requests. Each request disposes
its own cancellation source when it ends.
2026-10-02 20:22:24 -04:00
aj eace08f351 fix(server): reject NUL characters in nest query search
SQLite LIKE stops matching at an embedded NUL, so search=Alpha%00x
behaved like 'Alpha' followed by a wildcard and returned rows that do
not contain the whole search text. The shared query validation now
rejects NUL, so the server answers 400 and the client throws before
sending.
2026-10-02 20:16:00 -04:00
aj 96267e760e feat(desktop): page and filter database nests on the server
The Database-mode Open dialog no longer downloads every record. It
browses through NestBrowseSession, which sends one bounded query per
change: the filter box (300 ms debounce) searches on the server and
returns to the first page, column headers sort every match on the
server (a second click reverses), and Previous/Next page by 100 with a
range and total in the status line. A response superseded by a newer
request is cancelled and discarded, refresh steps back when the current
page was emptied, and failures clear the rows and show the error
without a modal box per keystroke.
2026-10-02 20:06:52 -04:00
aj 646d4c3975 feat(server): sort nest queries by allowlisted columns
The query accepts sort=<column>&order=asc|desc over a fixed allowlist
(saved, name, customer, status, material, dates, thickness, plate and
part counts, made by, comments, file size). Text columns sort with
NOCASE, ties break by id in the same direction so pages partition the
matches, and unknown or numeric sort values and other orders return 400.
The client sends the sort as its camelCase name.
2026-10-02 19:57:35 -04:00
aj 1fadf6c4d3 feat(server): add bounded nest query with SQL-side search
GET /api/nests/query filters name, customer, material, made by, comments
and status (stored and display names) with an escaped LIKE parameter,
orders newest saved first with an id tie-break, and returns one page of
at most 500 records plus the total match count, read in one database
hold. Unknown, repeated or out-of-range parameters return 400.

RemoteNestRepository.QueryAsync validates the same bounds before
sending, rejects oversized pages, and reports a 404 from an older server
as a server that needs updating. GET /api/nests stays the full
enumeration used by backup, restore checks and the container smoke.
2026-10-02 19:52:35 -04:00
aj f53bead0a8 fix(server-ci): verify exported image identity across Docker stores 2026-10-02 19:15:53 -04:00
aj 1ec79ae594 ci(server): validate and publish versioned GHCR images 2026-10-02 18:53:00 -04:00
aj d428357c8b feat(server): harden Docker runtime and document persistent deployment
- Run as the .NET image's non-root app user (UID 1654) with root-owned
  application files and an app-owned /app/data that fresh named volumes inherit.
- Add a curl HEALTHCHECK on /healthz (30s/5s/10s/3, 2s start interval), keep the
  explicit 8090 URL and clear the base image's 8080 port default.
- Parameterize VERSION/SOURCE_REVISION and base images; add OCI labels with
  development defaults.
- Add an image-only Compose example (required image and bind address, named
  volume, cap_drop ALL, no-new-privileges) and an env template.
- Document deployment, the upload limit, scoped ownership preparation, and
  checked backup/restore/upgrade functions that refuse existing destinations and
  verify the metadata list and every archive hash before switching volumes.
- Extend the container smoke: image user/healthcheck/label contract, PID 1 UID,
  capabilities and writable paths, Docker-reported health, near-limit and
  oversized uploads, stopped-service backup restored into a second volume, and
  startup failure on read-only and root-owned data mounts.
2026-10-02 17:23:36 -04:00
aj 142c77a27f fix(server): serialize SQLite access and validate storage health 2026-10-02 16:31:46 -04:00
aj b39509a6a3 fix(server): update SQLite to patched native library
Microsoft.Data.Sqlite 8.0.11 bundled SQLitePCLRaw.lib.e_sqlite3 2.1.6 (SQLite 3.41.2), affected by CVE-2025-6965 / GHSA-2m69-gcr7-jv3q. 8.0.31 bundles 2.1.12 (SQLite 3.53.3).
2026-10-02 16:10:24 -04:00
aj 6d1e0cb896 docs: list server integration tests in agent instructions 2026-10-02 16:06:31 -04:00
aj 5d8874a9e7 fix(server): reject malformed multipart uploads with 400 2026-10-02 15:48:55 -04:00
aj b17fe1a318 test(server): cover HTTP storage contracts with isolated SQLite 2026-10-02 15:37:44 -04:00
aj 7999e3cdde fix(server): repair Docker build and verify persistent client round trips 2026-10-02 14:17:13 -04:00
aj 14a7ce43a7 test: prevent selected-area fill from crossing sheet cutoffs 2026-10-02 07:12:15 -04:00
aj bd5db59e42 fix(irregular): preserve analytic line bounds after polygon cleanup 2026-10-01 20:45:22 -04:00
aj 25755a8850 fix(irregular): keep exact bounds for line-only part outlines
Orientation bounds were padded by the arc chord tolerance even when the outline has no arcs, so a part that exactly fills the work area after rotation was rejected before packing. Pad only outlines with flattened arcs; footprints and NFPs still use the chord tolerance.
2026-10-01 18:44:51 -04:00
aj c7b4ba4457 test(irregular): cover exact-contact rotated rectangle placement 2026-10-01 18:44:51 -04:00
aj d8b763da13 test(irregular): add six synthetic nests and bounded validity gate 2026-10-01 17:39:08 -04:00
aj c01a348243 feat(irregular): offer certified Fill blocks for repeated parts 2026-10-01 16:17:25 -04:00
aj e886bcfa35 fix(overlap): retain unchanged pair highlights during rechecks 2026-10-01 15:10:17 -04:00
aj 4fa0f636ce ci: run cross-platform suites on pull requests and master 2026-10-01 13:44:40 -04:00
aj 94e23e4b7a feat(irregular): place best-fit pairs through member free regions 2026-10-01 12:31:38 -04:00
74 changed files with 8982 additions and 353 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
**
!OpenNest.Server/
!OpenNest.Server/**
!OpenNest.Data/
!OpenNest.Data/**
!OpenNest.Core/
!OpenNest.Core/**
**/bin/**
**/obj/**
**/data/**
**/*.db
**/*.db-*
**/.env*
**/*.env
**/*.env.*
**/*.user
**/Properties/launchSettings.json
+34
View File
@@ -0,0 +1,34 @@
name: Cross-platform tests
on:
pull_request:
push:
branches: [master]
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Run OpenNest.Tests
run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj
- name: Run OpenNest.Engine.Tests
run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
- name: Check six synthetic Irregular nests
timeout-minutes: 6
run: |
python3 scripts/test_check_synthetic_nests.py -v
python3 scripts/check-synthetic-nests.py --parallel 2
- name: Run OpenNest.IO.Tests
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
- name: Run OpenNest.Server.Tests
run: dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
+287
View File
@@ -0,0 +1,287 @@
name: Server image
on:
pull_request:
paths:
- 'OpenNest.Server/**'
- 'OpenNest.Data/**'
- 'OpenNest.Core/**'
- 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh'
- 'scripts/test_server_container_cleanup.py'
- 'scripts/server_image_release.py'
- 'scripts/test_server_image_release.py'
- '.dockerignore'
- 'compose.server.yaml'
- '.github/workflows/server-image.yml'
push:
branches: [master]
paths:
- 'OpenNest.Server/**'
- 'OpenNest.Data/**'
- 'OpenNest.Core/**'
- 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh'
- 'scripts/test_server_container_cleanup.py'
- 'scripts/server_image_release.py'
- 'scripts/test_server_image_release.py'
- '.dockerignore'
- 'compose.server.yaml'
- '.github/workflows/server-image.yml'
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Approved existing vX.Y.Z tag to publish (dispatch from master only)'
required: true
type: string
permissions:
contents: read
defaults:
run:
shell: bash
# No tag-push trigger. Windows tag builds produce candidates, not publications.
jobs:
validate:
if: github.event_name == 'pull_request' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
VERSION: '0.0.0'
SOURCE_SHA: ${{ github.sha }}
LOCAL_IMAGE: opennest-server:ci
steps:
- name: Isolate logs, safe metadata and ephemeral auth
run: |
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
with:
dotnet-version: '8.0.x'
- name: Test fail-closed release and cleanup helpers
run: |
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
- name: Build and test Server
run: |
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
- name: Resolve base image digests
id: bases
run: python3 scripts/server_image_release.py bases
- name: Build exact local linux/amd64 image (never cached)
id: build
env:
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
run: |
set -euo pipefail
mkdir -p "$RESULTS"
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
python3 scripts/server_image_release.py local
- name: Freeze the inspected image identity for smoke and publication
env:
IMAGE_ID: ${{ steps.build.outputs.image_id }}
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
- name: Real-client persistence, backup/restore and runtime smoke
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
- name: Retain logs and safe provenance only
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-validation-${{ github.run_id }}
path: |
${{ runner.temp }}/server-image-results/**/*.log
${{ runner.temp }}/server-image-results/tests/*.trx
${{ runner.temp }}/server-image-metadata/bases.json
${{ runner.temp }}/server-image-metadata/local.json
retention-days: 14
publish:
if: >-
github.repository == 'ajisaacs/OpenNest' &&
(github.event_name == 'release' ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
packages: write
concurrency:
group: opennest-server-ghcr-publish
cancel-in-progress: false
env:
LOCAL_IMAGE: opennest-server:release
outputs:
source_sha: ${{ steps.source.outputs.source_sha }}
version: ${{ steps.source.outputs.version }}
manifest_digest: ${{ steps.readback.outputs.manifest_digest }}
config_digest: ${{ steps.readback.outputs.config_digest }}
steps:
- name: Isolate logs, safe metadata and ephemeral auth
run: |
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: master
fetch-depth: 0
persist-credentials: false
- name: Resolve approved tag, release event commit and master ancestry
id: source
env:
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: python3 scripts/server_image_release.py source
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ steps.source.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Use immutable source/version for every remaining gate
env:
VERSION: ${{ steps.source.outputs.version }}
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: |
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
printf 'VERSION=%s\nSOURCE_SHA=%s\n' "$VERSION" "$SOURCE_SHA" >> "$GITHUB_ENV"
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
with:
dotnet-version: '8.0.x'
- name: Test fail-closed release and cleanup helpers
run: |
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
- name: Build and test exact Server source
run: |
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
- name: Resolve base image digests
id: bases
run: python3 scripts/server_image_release.py bases
- name: Build exact local linux/amd64 image (never cached)
id: build
env:
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
run: |
set -euo pipefail
mkdir -p "$RESULTS"
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
python3 scripts/server_image_release.py local
- name: Freeze the inspected image identity for smoke and publication
env:
IMAGE_ID: ${{ steps.build.outputs.image_id }}
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
- name: Smoke the same local image before any registry write
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
- name: Refuse unknown visibility, wrong association, and both existing tags
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/server_image_release.py preflight
- name: Login and publish only the two immutable tags (no rebuild)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -m 700 -p "$DOCKER_CONFIG"
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
image=ghcr.io/ajisaacs/opennest-server
docker tag "$LOCAL_IMAGE" "$image:$VERSION"
docker tag "$LOCAL_IMAGE" "$image:sha-$SOURCE_SHA"
docker push "$image:$VERSION"
docker push "$image:sha-$SOURCE_SHA"
- name: Exact registry readback of both tags and smoked image config
id: readback
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/server_image_release.py readback
- name: Remove ephemeral Docker credentials
if: always()
run: rm -rf -- "$DOCKER_CONFIG"
- name: Retain logs and safe provenance only (not a verification verdict)
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-publication-${{ github.run_id }}
path: |
${{ runner.temp }}/server-image-results/**/*.log
${{ runner.temp }}/server-image-results/tests/*.trx
${{ runner.temp }}/server-image-metadata/source.json
${{ runner.temp }}/server-image-metadata/bases.json
${{ runner.temp }}/server-image-metadata/local.json
${{ runner.temp }}/server-image-metadata/preflight.json
${{ runner.temp }}/server-image-metadata/registry.json
retention-days: 14
verify-published:
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: read
env:
VERSION: ${{ needs.publish.outputs.version }}
SOURCE_SHA: ${{ needs.publish.outputs.source_sha }}
MANIFEST_DIGEST: ${{ needs.publish.outputs.manifest_digest }}
CONFIG_DIGEST: ${{ needs.publish.outputs.config_digest }}
steps:
- name: Isolate logs, safe metadata and ephemeral auth
run: |
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.publish.outputs.source_sha }}
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
with:
dotnet-version: '8.0.x'
- name: Pull returned digest on a clean runner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 -c 'import os,sys; sys.path.insert(0,"scripts"); from server_image_release import sha256,commit,version; sha256(os.environ["MANIFEST_DIGEST"]); sha256(os.environ["CONFIG_DIGEST"]); commit(os.environ["SOURCE_SHA"]); version("v"+os.environ["VERSION"])'
mkdir -m 700 -p "$DOCKER_CONFIG"
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
image="ghcr.io/ajisaacs/opennest-server@$MANIFEST_DIGEST"
docker pull --platform linux/amd64 "$image"
printf 'LOCAL_IMAGE=%s\n' "$image" >> "$GITHUB_ENV"
- name: Remove ephemeral Docker credentials
if: always()
run: rm -rf -- "$DOCKER_CONFIG"
- name: Verify pulled digest, config, platform and provenance
run: python3 scripts/server_image_release.py pulled
- name: Real-client persistence smoke of pulled digest
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
- name: Mark verified only after pulled-image smoke passes
run: printf 'Verified private server image `%s` from `%s`.\n' "$LOCAL_IMAGE" "$SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
- name: Retain logs and safe pulled-image provenance only
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-pulled-verification-${{ github.run_id }}
path: |
${{ runner.temp }}/server-image-results/**/*.log
${{ runner.temp }}/server-image-metadata/pulled.json
retention-days: 14
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' } if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Run all test projects on Windows - name: Run all test projects on Windows
run: | run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) { foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." } if ($LASTEXITCODE -ne 0) { throw "$project failed." }
} }
+1
View File
@@ -21,6 +21,7 @@ dotnet build OpenNest.sln
dotnet test OpenNest.Tests/OpenNest.Tests.csproj dotnet test OpenNest.Tests/OpenNest.Tests.csproj
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
# Windows runtime tests # Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
public static class ExecutionMotionReader
{
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
{
var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
public sealed class OwnedExecution
{
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
{
Motions = Array.AsReadOnly(motions.ToArray());
}
public IReadOnlyList<ExecutionMotion> Motions { get; }
public Vector DeparturePoint => Motions[^1].End;
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Length > PostVerificationGeometry.Epsilon);
public double RapidDistanceFrom(Vector arrival)
{
PostVerificationGeometry.Validate(arrival);
var total = 0.0;
for (var index = 0; index < Motions.Count; index++)
{
var move = Motions[index];
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
total += start.DistanceTo(move.End);
}
return total;
}
}
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
}
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState();
copy.obstacles.AddRange(obstacles);
return copy;
}
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
PostVerificationGeometry.Validate(point);
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, false, findings, 1, partNumber, token);
return findings.AsReadOnly();
}
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
}
@@ -32,6 +32,20 @@ public sealed class OverlapGeometryStamp
return true; return true;
} }
/// <summary>
/// Conservative display reuse: only unchanged ordered slots on the same plate survive.
/// Index-changing edits may hide extra pairs, but cannot attach old geometry to new parts.
/// </summary>
internal bool[] UnchangedSlots(Plate current)
{
var matches = new bool[entries.Length];
if (!ReferenceEquals(plate, current))
return matches;
for (var i = 0; i < entries.Length && i < current.Parts.Count; i++)
matches[i] = entries[i].Matches(current.Parts[i]);
return matches;
}
private readonly struct Entry private readonly struct Entry
{ {
private readonly Part part; private readonly Part part;
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Diagnostics; namespace OpenNest.Diagnostics;
@@ -9,12 +11,16 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail
public sealed class OverlapReportState public sealed class OverlapReportState
{ {
private OverlapGeometryStamp stamp; private OverlapGeometryStamp stamp;
private OverlapGeometryStamp displayStamp;
private OverlapGeometryStamp observedDisplayStamp;
private int uncheckedPartCount; private int uncheckedPartCount;
public long Generation { get; private set; } public long Generation { get; private set; }
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked; public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas; public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
public PlateOverlapReport Report { get; private set; } public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
public bool IsRunning => Status == OverlapCheckStatus.Checking; public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch public string Message => Status switch
@@ -36,7 +42,8 @@ public sealed class OverlapReportState
/// </summary> /// </summary>
public long Begin(Plate plate, bool automatic = false) public long Begin(Plate plate, bool automatic = false)
{ {
Clear(OverlapCheckStatus.Checking); RefreshDisplayPairs(plate);
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
stamp = OverlapGeometryStamp.Capture(plate); stamp = OverlapGeometryStamp.Capture(plate);
if (!automatic && DisplayMode == OverlapDisplayMode.Off) if (!automatic && DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas; DisplayMode = OverlapDisplayMode.Areas;
@@ -48,6 +55,9 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate)) if (!CanComplete(generation, plate))
return false; return false;
Report = report; Report = report;
DisplayPairs = report.Pairs;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues); uncheckedPartCount = CountUncheckedParts(report.Issues);
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete; Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
return true; return true;
@@ -66,20 +76,52 @@ public sealed class OverlapReportState
public bool EnsureFresh(Plate plate) public bool EnsureFresh(Plate plate)
{ {
RefreshDisplayPairs(plate);
if (stamp == null) if (stamp == null)
return false; return false;
if (stamp.Matches(plate)) if (stamp.Matches(plate))
return true; return true;
Invalidate(); Invalidate(plate);
return false; return false;
} }
/// <summary>Layout edit: retain only pairs whose two ordered slots still match exactly.</summary>
public void Invalidate(Plate plate)
{
RefreshDisplayPairs(plate);
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale, preserveDisplay: true);
}
/// <summary>In-place geometry edits and teardown must forget every cached display pair.</summary>
public void Invalidate() public void Invalidate()
{ {
ClearDisplayPairs();
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete) if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale); Clear(OverlapCheckStatus.Stale);
} }
private void RefreshDisplayPairs(Plate plate)
{
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
return;
var unchanged = displayStamp.UnchangedSlots(plate);
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly();
if (DisplayPairs.Count == 0)
ClearDisplayPairs();
else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
}
private void ClearDisplayPairs()
{
DisplayPairs = Array.Empty<PlateOverlapPair>();
displayStamp = null;
observedDisplayStamp = null;
}
public void Cancel() public void Cancel()
{ {
if (IsRunning) if (IsRunning)
@@ -88,8 +130,10 @@ public sealed class OverlapReportState
public void Reset() => Clear(OverlapCheckStatus.NotChecked); public void Reset() => Clear(OverlapCheckStatus.NotChecked);
private void Clear(OverlapCheckStatus status) private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
{ {
if (!preserveDisplay)
ClearDisplayPairs();
Generation++; Generation++;
Report = null; Report = null;
uncheckedPartCount = 0; uncheckedPartCount = 0;
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Diagnostics; namespace OpenNest.Diagnostics;
@@ -27,9 +28,14 @@ public static class PostVerificationAnalyzer
return new PostVerificationReport(findings); return new PostVerificationReport(findings);
} }
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) =>
Analyze(nest, Vector.Zero, cancellationToken);
public static PostVerificationReport Analyze(Nest nest, Vector startPoint,
CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(nest); ArgumentNullException.ThrowIfNull(nest);
PostVerificationGeometry.Validate(startPoint);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var findings = new List<PostVerificationFinding>(); var findings = new List<PostVerificationFinding>();
if (nest.Plates == null) if (nest.Plates == null)
@@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer
} }
var materialParts = new List<Part>(); var materialParts = new List<Part>();
var indices = new List<int>(); var indices = new List<int>();
var obstacles = new List<Obstacle>(); var obstacles = new ReleasedContourState();
Vector? position = Vector.Zero; Vector? position = startPoint;
for (var index = 0; index < plate.Parts.Count; index++) for (var index = 0; index < plate.Parts.Count; index++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -65,7 +71,7 @@ public static class PostVerificationAnalyzer
{ {
try try
{ {
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken); var clean = ExecutionMotionReader.Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken).Motions;
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe); expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
if (!clean.Any(move => move.Layer == LayerType.Scribe) if (!clean.Any(move => move.Layer == LayerType.Scribe)
|| expectsCuts) || expectsCuts)
@@ -83,12 +89,12 @@ public static class PostVerificationAnalyzer
{ {
if (part == null || !double.IsFinite(part.Rotation)) if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation."); throw new ArgumentException("Missing part or invalid rotation.");
var moves = Read(part.Program, part.Location, position, cancellationToken); var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
if (expectsCuts && !moves.Any(move => !move.Rapid if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display && move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon)) && move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing."); Incomplete("Placed program has no cutting contour motions for this drawing.");
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken); obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
position = moves[^1].End; position = moves[^1].End;
} }
catch (Exception exception) when (IsInvalid(exception)) catch (Exception exception) when (IsInvalid(exception))
@@ -119,172 +125,4 @@ public static class PostVerificationAnalyzer
private static bool IsInvalid(Exception exception) => exception is private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static List<Move> Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
{
var moves = new List<Move>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return moves;
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
PostVerificationGeometry.Curve Curve);
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
} }
+7 -1
View File
@@ -7,9 +7,15 @@ namespace OpenNest.Data;
/// </summary> /// </summary>
public interface INestRepository public interface INestRepository
{ {
/// <summary>All stored nests, newest saved first.</summary> /// <summary>
/// All stored nests, newest saved first. This is the full-enumeration contract for
/// backup and verification; browsing uses <see cref="QueryAsync"/>.
/// </summary>
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
/// <summary>One bounded page of nests matching the query, filtered and ordered by the server.</summary>
Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default); Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary> /// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
+187
View File
@@ -0,0 +1,187 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>
/// Browse state for the Database-mode Open dialog: the requested search, sort and page,
/// and the last page applied. Every change issues one bounded server query. A response
/// that a later request superseded is discarded (and the earlier request is cancelled),
/// so an older result can never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public sealed class NestBrowseSession : IDisposable
{
private readonly INestRepository _repository;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit)
{
ArgumentNullException.ThrowIfNull(repository);
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {NestQuery.MaxLimit}.");
_repository = repository;
PageSize = pageSize;
}
public int PageSize { get; }
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public NestSortField Sort { get; private set; } = NestSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public NestPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
return Search.Length == 0 ? "No nests on the server." : "No nests match the filter.";
if (page.Items.Count == 0)
return $"No nests on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} nests";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(NestSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// A deletion or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
_repository.QueryAsync(
new NestQuery
{
Search = Search,
Sort = Sort,
Descending = Descending,
Offset = Offset,
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the repository is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace OpenNest.Data;
/// <summary>One page of saved-nest metadata returned by <c>GET /api/nests/query</c>.</summary>
public sealed class NestPage
{
/// <summary>At most <see cref="Limit"/> matching records, in the requested order.</summary>
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
/// <summary>Number of records matching the search across all pages.</summary>
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
+50
View File
@@ -0,0 +1,50 @@
namespace OpenNest.Data;
/// <summary>
/// One bounded page request for browsing saved nests (<c>GET /api/nests/query</c>).
/// The server filters and pages in SQL; the client and server share these bounds.
/// </summary>
public sealed class NestQuery
{
public const int DefaultLimit = 100;
public const int MaxLimit = 500;
public const int MaxSearchLength = 200;
/// <summary>
/// Case-insensitive substring matched against name, customer, material, made by,
/// comments and status (stored and display names). Blank means no filter.
/// </summary>
public string Search { get; init; } = "";
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
public NestSortField Sort { get; init; } = NestSortField.SavedAt;
/// <summary>Largest/newest/Z first when true (the default, newest saved first).</summary>
public bool Descending { get; init; } = true;
/// <summary>Number of matching records to skip, zero or greater.</summary>
public int Offset { get; init; }
/// <summary>Maximum records returned, 1 to <see cref="MaxLimit"/>.</summary>
public int Limit { get; init; } = DefaultLimit;
/// <summary>The search text as matched: trimmed, never null.</summary>
public string NormalizedSearch => (Search ?? "").Trim();
/// <summary>The first violated bound, or null when the query may be sent.</summary>
public string? GetValidationError()
{
if (!Enum.IsDefined(Sort))
return "sort must be a supported column.";
if (Offset < 0)
return "offset must be zero or greater.";
if (Limit < 1 || Limit > MaxLimit)
return $"limit must be between 1 and {MaxLimit}.";
if (NormalizedSearch.Length > MaxSearchLength)
return $"search must be at most {MaxSearchLength} characters.";
// SQLite LIKE stops at an embedded NUL, which would turn the rest of the text into a wildcard.
if (NormalizedSearch.Contains('\0'))
return "search must not contain NUL characters.";
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace OpenNest.Data;
/// <summary>Columns a saved-nest browse query may order by (camelCase on the wire).</summary>
public enum NestSortField
{
SavedAt,
Name,
Customer,
Status,
Material,
DateCreated,
DateModified,
Thickness,
PlateCount,
PartCount,
MadeBy,
Comments,
FileSize,
}
+35
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@@ -58,6 +59,40 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return items ?? new List<NestRecord>(); return items ?? new List<NestRecord>();
} }
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
if (error is not null)
throw new ArgumentException(error, nameof(query));
using var response = await _httpClient.GetAsync(
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new IOException(
"This nest server does not support filtered browsing (HTTP 404). Update the nest server to this OpenNest version.");
}
await EnsureSuccess(response, "query nests", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestPage>(JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty page.");
if (page.Items.Count > query.Limit)
{
throw new IOException(
$"Nest server returned {page.Items.Count} records for a page limited to {query.Limit}.");
}
return page;
}
private static string BuildQueryString(NestQuery query) =>
"search=" + Uri.EscapeDataString(query.NormalizedSearch)
+ "&sort=" + JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())
+ "&order=" + (query.Descending ? "desc" : "asc")
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
{ {
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken); using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
@@ -0,0 +1,45 @@
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.Tests.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests;
public class CutOffBoundedFillTests
{
public static IEnumerable<object[]> Cases()
{
foreach (var strategy in PlateFillService.BuiltInStrategies)
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var quadrant in new[] { 1, 3 })
foreach (var farSide in new[] { false, true })
yield return new object[] { strategy, axis, quadrant, farSide };
}
[Theory]
[MemberData(nameof(Cases))]
public void FillItemCannotCrossSelectedCutOffBoundary(
string strategy, CutOffAxis axis, int quadrant, bool farSide)
{
var plate = new Plate(new Size(20, 20)) { Quadrant = quadrant, PartSpacing = 0.5 };
var bounds = plate.WorkArea();
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
plate.CutOffs.Add(new CutOff(position, axis));
// Independent expected selection box: one side of the cutoff, with spacing.
var area = axis == CutOffAxis.Vertical
? new Box(farSide ? position.X + 0.5 : bounds.Left, bounds.Bottom, 9.5, bounds.Width)
: new Box(bounds.Left, farSide ? position.Y + 0.5 : bounds.Bottom, bounds.Length, 9.5);
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(3, 3));
var parts = PlateFillService.FillItem(strategy, plate,
new NestItem { Drawing = drawing }, area, null, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.Empty(plate.Parts);
Assert.All(parts, part =>
{
Assert.Same(drawing, part.BaseDrawing);
var box = part.BoundingBox;
Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6);
Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6);
});
}
}
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularBlockTests
{
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[Theory]
[InlineData(1)]
[InlineData(2)]
public void SmallDemandNeverPreparesAFill(int quantity)
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
Assert.Equal(0, catalog.PreparationCount);
}
[Fact]
public void FillProposalIsTrimmedAndCertifiedWithoutChangingSingleRotations()
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 7, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
var original = types[0].Orientations.ToArray();
using var catalog = new BlockCatalog(0.25, types, NoPairs);
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
Assert.Equal(7, block.Count);
Assert.Equal(original, types[0].Orientations);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
for (var i = 0; i < block.Count; i++)
for (var j = i + 1; j < block.Count; j++)
Assert.True(NestLayoutCheck.Clears(geometry,
new NestJobPlacement("ell", i, block[i].X, block[i].Y, block[i].Orientation.Rotation), geometry,
new NestJobPlacement("ell", j, block[j].X, block[j].Y, block[j].Orientation.Rotation), 0.25));
Assert.Same(block, catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None));
}
[Theory]
[InlineData(0)]
[InlineData(2.1)]
public void InvalidInternalSpacingRejectsTheWholeProposal(double offset)
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
var members = Enumerable.Range(0, 3).Select(i => new OpenNest.Part(drawing)
{ Location = new Vector(i * offset, 0) }).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void PhysicalFreeRectanglesExcludeOccupiedMaterialAndClearance()
{
var job = Job(new[] { Rectangle("box", 4, 10, 1) }, new[] { Stock("sheet", 10, 20) });
var type = PartCatalog.Build(job)[0];
var part = new Placed(type.Orientations[0], 8, 0);
var rectangles = BlockCatalog.Rectangles(new Box(0, 0, 20, 10), new[] { part }, 0.25);
Assert.NotEmpty(rectangles);
Assert.All(rectangles, box => Assert.True(box.Right <= 7.75 || box.Left >= 12.25));
}
[Fact]
public void RepeatedEllBlockCompetesWithPairsOnAnOpenSheet()
{
var stock = Stock("sheet", 30, 40, spacing: 0.25);
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
using var blocks = new BlockCatalog(0.25, types, pairs);
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 44 }, CancellationToken.None);
Assert.True(after.Parts.Count > before.Parts.Count,
$"Before {before.Parts.Count}; after {after.Parts.Count}");
var result = new NestJobResultBuilder(job);
result.AddSheet(stock, after.Parts.Select(p => ("ell", p.X, p.Y, p.Orientation.Rotation)));
LayoutAssert.Valid(job, result.Build(NestJobStopReason.StockExhausted));
}
[Fact]
public void UndersizedRectangleKeepsSinglesFallback()
{
var stock = Stock("sheet", 3, 3, spacing: 0.25);
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
Assert.Equal(0, blocks.PreparationCount);
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 3 }, CancellationToken.None);
Assert.Single(fill.Parts);
}
[Fact]
public void ForbiddenRotationsAreRejectedBeforeAddingGroupOrientations()
{
var program = LShape(9, 7, 3);
var job = Job(new[] { Part("ell", program, 3, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("ell", program);
var members = Enumerable.Range(0, 3).Select(i =>
{
var part = OpenNest.Part.CreateAtOrigin(drawing, System.Math.PI / 2);
part.Offset(new Vector(i * 12, 0));
return part;
}).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void CancellationDuringCertificationPropagates()
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
using var cancellation = new CancellationTokenSource();
var members = new CancellingMembers(Enumerable.Range(0, 3)
.Select(i => new OpenNest.Part(drawing) { Location = new Vector(i * 3, 0) }).ToArray(), cancellation);
Assert.Throws<OperationCanceledException>(() => BlockCatalog.Resolve(type, members,
type.Orientations.ToList(), 0.25, cancellation.Token));
}
private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation)
: IReadOnlyList<OpenNest.Part>
{
public int Count => parts.Length;
public OpenNest.Part this[int index] => parts[index];
public IEnumerator<OpenNest.Part> GetEnumerator()
{
foreach (var part in parts)
yield return part;
cancellation.Cancel();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public void CancellationIsNotConvertedIntoAnEmptyProposal()
{
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Throws<OperationCanceledException>(() =>
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
}
}
@@ -0,0 +1,276 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// A part whose material exactly fills the work area in one allowed rotation. The engine must
/// place it: preparation padding for arc flattening may not shrink a line-only part's room.
/// </summary>
public class IrregularExactContactTests
{
private const double PartLength = 8;
private const double PartWidth = 3;
private const double Edge = 0.25;
private const double Spacing = 0.25;
/// <summary>Gap shortfall the independent oracle tolerates (float noise only).</summary>
private const double OracleNoise = 1e-9;
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ExactContactRotatedRectangleIsPlacedInEveryQuadrant(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
var result = new IrregularNestingEngine().Solve(job);
AssertPlacedSafely(job, result);
}
[Theory]
[InlineData(8.501, 3.501)]
[InlineData(8.5, 3.501)]
[InlineData(8.501, 3.5)]
[InlineData(8.52, 3.52)] // Generator smoke stock: a looser control, not exact-contact acceptance.
public void LargerNeighbouringStockStillPlacesThePart(double width, double length)
{
var job = RectangleJob(width, length, 1, RotationPolicy.Automatic);
AssertPlacedSafely(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(8.499, 3.5, 1)]
[InlineData(8.5, 3.499, 1)]
[InlineData(8.499, 3.499, 3)]
public void StockOneThousandthTooSmallLeavesThePartUnplaced(double width, double length, int quadrant)
{
var job = RectangleJob(width, length, quadrant, RotationPolicy.Automatic);
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void ZeroRotationOnlyCannotFitAndStaysUnplaced(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Fixed(0));
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void HandPoseAtExactContactPassesAndOneThousandthOverEdgeFails(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
var stock = job.Plates[0];
var work = stock.WorkArea;
// +90 degrees maps the source rectangle to X in [-3, 0], Y in [0, 8].
var exact = new NestJobPlacement("rect", 0, work.Left + PartWidth, work.Bottom, System.Math.PI / 2);
Assert.True(OracleGaps(stock, exact).Min() >= Edge - OracleNoise);
Assert.Empty(Violations(job, exact));
Validate(job, exact);
foreach (var (dx, dy) in new[] { (0.001, 0.0), (-0.001, 0.0), (0.0, 0.001), (0.0, -0.001) })
{
var over = exact with { X = exact.X + dx, Y = exact.Y + dy };
Assert.True(OracleGaps(stock, over).Min() < Edge - 0.0009);
Assert.NotEmpty(Violations(job, over));
Assert.Throws<InvalidOperationException>(() => Validate(job, over));
}
}
[Fact]
public void LineOnlyBoundsAreExactWhileFlatteningToleranceIsKept()
{
var job = RectangleJob(8.5, 3.5, 1, RotationPolicy.Automatic);
var type = PartCatalog.Build(job).Single();
var rotated = type.Orientations.Single(o => System.Math.Abs(o.Rotation - System.Math.PI / 2) < 1e-9);
// Chord tolerance still drives tessellation and NFP footprints.
Assert.Equal(PartCatalog.ChordTolerance, rotated.Tolerance);
Assert.Equal(-PartWidth, rotated.MinX, 12);
Assert.Equal(0, rotated.MinY, 12);
Assert.Equal(0, rotated.MaxX, 12);
Assert.Equal(PartLength, rotated.MaxY, 12);
Assert.True(job.Plates[0].Fits(rotated.Width, rotated.Height));
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(1.2)]
public void ArcBoundsStillContainTheTruePerimeterAndTheFlattenedOutline(double angle)
{
var job = Job(new[] { Part("obround", Shapes.Obround(9, 4), 1, RotationPolicy.Fixed(angle)) },
new[] { Stock("sheet", 40, 40, spacing: Spacing) });
var type = PartCatalog.Build(job).Single();
var o = type.Orientations.Single();
Assert.True(o.Tolerance >= PartCatalog.ChordTolerance);
// Independent oracle: sample the stadium analytically (straight sides plus semicircles).
const double length = 9, width = 4, r = width / 2;
var points = new List<(double X, double Y)>();
for (var i = 0; i <= 2000; i++)
{
var t = System.Math.PI * i / 2000;
points.Add((1 + length - r + r * System.Math.Sin(t), 1 + r - r * System.Math.Cos(t)));
points.Add((1 + r - r * System.Math.Sin(t), 1 + r + r * System.Math.Cos(t)));
}
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
foreach (var (x, y) in points)
{
var rx = x * c - y * s;
var ry = x * s + y * c;
Assert.InRange(rx, o.MinX - 1e-9, o.MaxX + 1e-9);
Assert.InRange(ry, o.MinY - 1e-9, o.MaxY + 1e-9);
}
foreach (var p in o.Outline)
{
Assert.InRange(p.x, o.MinX, o.MaxX);
Assert.InRange(p.y, o.MinY, o.MaxY);
}
}
[Theory]
[InlineData(0.0, false)]
[InlineData(System.Math.PI / 2, false)]
[InlineData(System.Math.PI, false)]
[InlineData(3 * System.Math.PI / 2, false)]
[InlineData(0.0, true)]
[InlineData(System.Math.PI / 2, true)]
[InlineData(System.Math.PI, true)]
[InlineData(3 * System.Math.PI / 2, true)]
public void ShortEdgeChainBoundsKeepEveryAnalyticEndpoint(double angle, bool extraOrientation)
{
var job = Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1,
RotationPolicy.Fixed(extraOrientation ? 0 : angle)) }, new[] { Stock("sheet", 20, 20) });
var type = PartCatalog.Build(job).Single();
var orientation = extraOrientation
? PartCatalog.CreateOrientation(type, 100, angle)!
: type.Orientations.Single();
Assert.NotNull(orientation);
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
var endpoints = ShortEdgeChainVertices()
.Select(p => (X: p.X * c - p.Y * s, Y: p.X * s + p.Y * c)).ToArray();
Assert.Equal(endpoints.Min(p => p.X), orientation.MinX, 12);
Assert.Equal(endpoints.Min(p => p.Y), orientation.MinY, 12);
Assert.Equal(endpoints.Max(p => p.X), orientation.MaxX, 12);
Assert.Equal(endpoints.Max(p => p.Y), orientation.MaxY, 12);
Assert.Equal(PartCatalog.ChordTolerance, orientation.Tolerance);
}
[Fact]
public void ShortEdgeChainCannotEscapeAnExactWorkArea()
{
var job = ShortEdgeChainJob(8.5);
var result = new IrregularNestingEngine().Solve(job);
Assert.Empty(NestLayoutCheck.Violations(job, result));
AssertUnplaced(job, result);
}
[Fact]
public void ShortEdgeChainFitsWhenStockContainsItsTrueExtent()
{
var job = ShortEdgeChainJob(8.500024);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal((1, 1, 0), (result.Fulfillment.Single().Requested,
result.Fulfillment.Single().Placed, result.Fulfillment.Single().Unplaced));
var sheet = Assert.Single(result.Plates);
var pose = Assert.Single(sheet.Placements);
Assert.True(OracleGaps(sheet.Stock, pose, ShortEdgeChainVertices()).Min() >= Edge - OracleNoise);
LayoutAssert.Valid(job, result);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Validate(job, pose);
}
// Polygon cleanup can collapse these individually short edges, but their accumulated
// outward extent is real material and exceeds the unchanged work-area slack.
private static (double X, double Y)[] ShortEdgeChainVertices() =>
[(0, 0), (0, 3), (8, 3), (8.000008, 2.999992),
(8.000016, 2.999984), (8.000024, 2.999976), (8, 0)];
private static NestJob ShortEdgeChainJob(double length) =>
Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 3.5, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge)) });
private static NestJob RectangleJob(double width, double length, int quadrant, RotationPolicy rotation) =>
Job(new[] { Rectangle("rect", PartLength, PartWidth, 1, rotation) },
new[] { Stock("sheet", width, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge), quadrant: quadrant) });
private static void AssertPlacedSafely(NestJob job, NestJobResult result)
{
Assert.Equal(NestJobStatus.Complete, result.Status);
var fulfillment = Assert.Single(result.Fulfillment);
Assert.Equal((1, 1, 0), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
var sheet = Assert.Single(result.Plates);
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
var pose = Assert.Single(sheet.Placements);
Assert.True(OracleGaps(sheet.Stock, pose).Min() >= Edge - OracleNoise,
$"pose ({pose.X:R}, {pose.Y:R}, {pose.Rotation:R}) gaps {string.Join(", ", OracleGaps(sheet.Stock, pose))}");
LayoutAssert.Valid(job, result);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Validate(job, pose);
}
private static void AssertUnplaced(NestJob job, NestJobResult result)
{
Assert.NotEqual(NestJobStatus.Complete, result.Status);
Assert.Empty(result.Plates);
var fulfillment = Assert.Single(result.Fulfillment);
Assert.Equal((1, 0, 1), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
LayoutAssert.Valid(job, result);
}
/// <summary>Clearance from the rotated nominal rectangle to each physical sheet edge
/// (left, bottom, right, top), computed from corners and the quadrant's sheet origin.</summary>
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose) =>
OracleGaps(stock, pose, [(0, 0), (PartLength, 0), (PartLength, PartWidth), (0, PartWidth)]);
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose,
(double X, double Y)[] vertices)
{
var (c, s) = (System.Math.Cos(pose.Rotation), System.Math.Sin(pose.Rotation));
var corners = vertices
.Select(p => (X: p.X * c - p.Y * s + pose.X, Y: p.X * s + p.Y * c + pose.Y))
.ToArray();
var sheetLeft = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
var sheetBottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
return new[]
{
corners.Min(p => p.X) - sheetLeft,
corners.Min(p => p.Y) - sheetBottom,
sheetLeft + stock.Size.Length - corners.Max(p => p.X),
sheetBottom + stock.Size.Width - corners.Max(p => p.Y),
};
}
private static IReadOnlyList<string> Violations(NestJob job, NestJobPlacement pose)
{
var stock = job.Plates[0];
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { pose }) },
new[] { new PartFulfillment("rect", 1, 1, 0) }, new[] { new StockUsage(stock.Id, 1, null) });
return NestLayoutCheck.Violations(job, result);
}
private static void Validate(NestJob job, NestJobPlacement pose) =>
NestJobValidator.ValidateCandidate(new PlateCandidate(new[] { pose }), job.Plates[0],
new Dictionary<string, int> { ["rect"] = 1 }, job.Parts.ToDictionary(p => p.Id));
}
@@ -0,0 +1,229 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularPairTests
{
// A 20-long wedge, 8 wide at one end and 6 at the other. Lying down, two copies need
// 8 + 0.25 + 8 = 16.25 across, but nested slant-to-slant they need only about 14.25.
// The 30 x 15 sheet holds the pair only when the two copies interlock.
private static OpenNest.CNC.Program Wedge() => Polyline((0, 0), (8, 0), (6, 20), (0, 20));
private static OpenNest.CNC.Program MirroredWedge() => Polyline((0, 0), (8, 0), (8, 20), (2, 20));
public static TheoryData<string> Wedges => new() { "wedge", "mirrored" };
private static OpenNest.CNC.Program NativeU()
{
// Same native semicircular U as the shared curve-contact regression; no DXF dependency.
var p = new OpenNest.CNC.Program();
p.MoveTo(2.5, 0.625);
p.LineTo(2.5, 0);
p.LineTo(1.5, 0);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 3, 1.5, 1.5, OpenNest.RotationType.CW));
p.LineTo(2.5, 3);
p.LineTo(2.5, 2.375);
p.LineTo(1.5, 2.375);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 0.625, 1.5, 1.5, OpenNest.RotationType.CCW));
p.LineTo(2.5, 0.625);
return p;
}
[Fact]
public void NativeUQuantityTwoOffersAndPlacesAnInterlockedClearPair()
{
var job = Job(new[] { Part("native-u", NativeU(), 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 6, 6, spacing: 0.25) });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 6, 6, CancellationToken.None);
Assert.NotEmpty(pairs[0]);
var pair = pairs[0][0];
// Bounding boxes overlap on both axes: this is an interlock, not adjacent boxes.
Assert.True(System.Math.Min(pair.A.MaxX, pair.Dx + pair.B.MaxX)
> System.Math.Max(pair.A.MinX, pair.Dx + pair.B.MinX));
Assert.True(System.Math.Min(pair.A.MaxY, pair.Dy + pair.B.MaxY)
> System.Math.Max(pair.A.MinY, pair.Dy + pair.B.MinY));
var tightJob = Job(job.Parts.ToArray(),
new[] { Stock("tight", pair.Height + 0.01, pair.Width + 0.01, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(tightJob);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
LayoutAssert.Valid(tightJob, result);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
Assert.Empty(geometry.Cutouts);
Assert.Equal(2.5, geometry.Bounds.Length, 9);
Assert.Equal(3, geometry.Bounds.Width, 9);
var outlines = result.Plates[0].Placements.Select(placement =>
{
var shape = (OpenNest.Geometry.Shape)geometry.Perimeter.Clone();
shape.Rotate(placement.Rotation);
shape.Offset(placement.X, placement.Y);
return shape.ToPolygonWithTolerance(1e-6);
}).ToArray();
// Raw fine boundaries, independent of BestFit's offsets and the NFP free regions.
Assert.True(OpenNest.Geometry.Clearance.Between(outlines[0], outlines[1]).Distance >= 0.25 - 2e-6);
Assert.True(outlines[0].BoundingBox.Right > outlines[1].BoundingBox.Left
&& outlines[1].BoundingBox.Right > outlines[0].BoundingBox.Left);
Assert.True(outlines[0].BoundingBox.Top > outlines[1].BoundingBox.Bottom
&& outlines[1].BoundingBox.Top > outlines[0].BoundingBox.Bottom);
}
[Theory]
[InlineData(0.0)] // Coincident material.
[InlineData(3.1)] // Disjoint outlines, but only 0.1 clearance rather than 0.25.
public void PairResolutionRejectsOverlapAndInsufficientSpacing(double offset)
{
var program = NativeU();
var job = Job(new[] { Part("native-u", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 10, 10, spacing: 0.25) });
var type = Assert.Single(PartCatalog.Build(job));
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
var drawing = new Drawing("native-u", program);
var members = new List<OpenNest.Part>
{
new(drawing),
new(drawing) { Location = new OpenNest.Geometry.Vector(0, offset) },
};
var extra = new List<Orientation>();
Assert.Null(PairCatalog.Resolve(type, extra, geometry, members, 0, 0.25));
Assert.Empty(extra);
}
[Fact]
public void PairOnlyOrientationRemainsEligibleWhenSampledSinglesDoNotFit()
{
var wedge = MirroredWedge();
wedge.Rotate(System.Math.PI / 4);
// A many-type job samples only two single orientations. The pair adds its own
// legal rotations; neither sampled single fits this narrow stock.
var parts = Enumerable.Range(0, 24).Select(i => Rectangle($"oversized-{i}", 100, 100, 1)).ToList();
parts.Insert(0, Part("wedge", wedge, 2, RotationPolicy.Automatic));
var stock = Stock("sheet", 15, 30, spacing: 0.25);
var job = Job(parts.ToArray(), new[] { stock });
var types = PartCatalog.Build(job);
Assert.DoesNotContain(types[0].Orientations, o => stock.Fits(o.Width, o.Height));
var pairs = PairCatalog.Build(types, 0.25, 30, 15, CancellationToken.None);
Assert.Contains(pairs[0], p => stock.Fits(p.Width, p.Height));
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void QuantityTwoInterlocksWhenOnlyAPairFits(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void AutomaticRotationAlsoFindsThePair(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
}
[Fact]
public void PairsNeverUseARotationThePolicyForbids()
{
// Fixed at 0 with no 180-degree twin: every best-fit pair turns its second copy,
// so no pair is allowed and the parts must be placed as singles.
var job = Job(
new[] { Part("wedge", Wedge(), 2, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 25, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0, p.Rotation, 9));
}
[Fact]
public void OddDemandPlacesTheLastCopyAsASingleWithoutOverdrawing()
{
var job = Job(new[] { Part("wedge", MirroredWedge(), 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { 1, 2 }, result.Plates.Select(p => p.Placements.Count).OrderBy(n => n));
}
[Fact]
public void PairMembersLeaveTheirEnclosedGapAvailableForLaterParts()
{
var stock = Stock("sheet", 6.1, 6.1, spacing: 0.1);
var job = Job(new[] { Rectangle("bar", 2, 6, 2), Rectangle("insert", 1, 1, 1) }, new[] { stock });
var types = PartCatalog.Build(job);
var bar = Assert.Single(types[0].Orientations);
var pair = new PairPose { A = bar, B = bar, Dx = 4, Dy = 0 };
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
// Along Y the pair buys twice the area for the same advance, so it wins first.
var fill = new FrontierPacker(types, new NoFitCache(0.1), pairs, stock,
PackAxis.Y, 1, new WorkCounter()).Fill(new[] { 2, 1 }, CancellationToken.None);
Assert.Equal(3, fill.Parts.Count);
Assert.Equal(0, fill.Parts[0].Orientation.TypeIndex);
Assert.Equal(0, fill.Parts[1].Orientation.TypeIndex);
var insert = fill.Parts[2];
Assert.Equal(1, insert.Orientation.TypeIndex);
Assert.True(insert.Left > fill.Parts[0].Right);
Assert.True(insert.Right < fill.Parts[1].Left);
var geometries = job.Parts.Select(p => JobPartGeometry.Read(p.Geometry)).ToArray();
for (var i = 0; i < fill.Parts.Count; i++)
for (var j = i + 1; j < fill.Parts.Count; j++)
{
var a = fill.Parts[i];
var b = fill.Parts[j];
Assert.True(NestLayoutCheck.Clears(geometries[a.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[a.Orientation.TypeIndex].Id, i, a.X, a.Y, a.Orientation.Rotation),
geometries[b.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[b.Orientation.TypeIndex].Id, j, b.X, b.Y, b.Orientation.Rotation), 0.1));
}
}
[Fact]
public void PairedJobsAreDeterministic()
{
// Best-fit evaluates in parallel; equal-area pairs must still be chosen the same way.
NestJob Build() =>
Job(
new[]
{
Part("wedge", Wedge(), 6, RotationPolicy.Automatic),
Part("ell", LShape(9, 7, 3), 4, RotationPolicy.Automatic),
},
new[] { Stock("sheet", 30, 40, spacing: 0.25) }
);
var first = new IrregularNestingEngine().Solve(Build());
var second = new IrregularNestingEngine().Solve(Build());
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
}
}
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Caller-side input for a fixed-program, direct-XY route only. Keep all sources stable
/// during Capture. Both locked and unlocked programs are fixed; this is not an Apply request.
/// </summary>
public sealed class CuttingPlanRequest
{
public CuttingPlanRequest(IEnumerable<Part> parts, Vector startPoint = default, int expansionBudget = 20000)
{
Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray());
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
}
public IReadOnlyList<Part> Parts { get; }
public Vector StartPoint { get; }
public int ExpansionBudget { get; }
}
/// <summary>Owned worker input. SourcePart references are identity handles only, never worker data.</summary>
public sealed class CuttingPlanSnapshot
{
internal CuttingPlanSnapshot(IEnumerable<FixedProgramPlacement> placements, Vector startPoint,
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null)
{
Placements = Array.AsReadOnly(placements.ToArray());
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
Failure = failure;
Findings = Array.AsReadOnly((findings ?? []).ToArray());
}
public IReadOnlyList<FixedProgramPlacement> Placements { get; }
public Vector StartPoint { get; }
public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
}
public sealed class FixedProgramPlacement
{
internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location,
double rotation, bool leadInsLocked, OwnedExecution execution)
{
SourcePart = sourcePart;
SourceOrdinal = sourceOrdinal;
Location = location;
Rotation = rotation;
LeadInsLocked = leadInsLocked;
Execution = execution;
}
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
public double Rotation { get; }
public bool LeadInsLocked { get; }
public OwnedExecution Execution { get; }
}
public enum CuttingPlanStatus
{
Ready,
ConstraintConflict,
UnsupportedGeometry,
InvalidInput,
NoSolutionWithinBudget,
Cancelled
}
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message);
/// <summary>
/// A checked fixed-program route, not full cutting-plan readiness, posting consent,
/// physical safety, or an atomic Apply payload. Failures contain no proposed order.
/// </summary>
public sealed class CuttingPlanResult
{
internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable<FixedProgramPlacement> order = null,
IEnumerable<CuttingPlanFinding> findings = null, int expansions = 0,
double rapidDistance = 0, bool independentlyReplayed = false)
{
Status = status;
ProposedOrder = Array.AsReadOnly((order ?? []).ToArray());
Findings = Array.AsReadOnly((findings ?? []).ToArray());
Expansions = expansions;
RapidDistance = rapidDistance;
IndependentlyReplayed = independentlyReplayed;
}
public CuttingPlanStatus Status { get; }
public IReadOnlyList<FixedProgramPlacement> ProposedOrder { get; }
public IReadOnlyList<CuttingPlanFinding> Findings { get; }
public int Expansions { get; }
public double RapidDistance { get; }
public bool IndependentlyReplayed { get; }
}
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
public static class CuttingPlanService
{
/// <summary>
/// Read stable caller-owned sources once, before dispatching worker work. No Program.Clone,
/// private Plates, settings aliases, quantity updates, or subcall rebinding are involved.
/// </summary>
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
{
var placements = new List<FixedProgramPlacement>();
Part source = null;
int? ordinal = null;
try
{
token.ThrowIfCancellationRequested();
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0)
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
// Reuse the reader's coordinate validation without publishing its native kernel.
var identities = new HashSet<Part>(ReferenceEqualityComparer.Instance);
for (var index = 0; index < request.Parts.Count; index++)
{
token.ThrowIfCancellationRequested();
source = request.Parts[index];
ordinal = index;
if (source?.BaseDrawing == null || source.Program == null || !double.IsFinite(source.Rotation)
|| !identities.Add(source))
throw new ArgumentException("Missing/duplicate source placement or invalid pose.");
if (source.BaseDrawing.IsCutOff)
throw new NotSupportedException("Cutoff dependency ordering is outside the fixed-program route slice.");
var clean = ExecutionMotionReader.Read(source.BaseDrawing.Program, Vector.Zero, null, token);
if (!clean.HasCuttingContour)
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
var execution = ExecutionMotionReader.Read(source.Program, source.Location, request.StartPoint, token);
if (!execution.HasCuttingContour)
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked, execution));
}
// The start can be invalid even when the first rapid has no cutting geometry.
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
return new(placements, request.StartPoint, request.ExpansionBudget);
}
catch (OperationCanceledException)
{
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
}
catch (NotSupportedException exception)
{
return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or ArithmeticException)
{
return Failure(CuttingPlanStatus.InvalidInput, exception.Message);
}
CuttingPlanSnapshot Failure(CuttingPlanStatus status, string message) =>
new(placements, request?.StartPoint ?? Vector.Zero, request?.ExpansionBudget ?? 0, status,
[new(ordinal, source, null, null, null, message)]);
}
public static CuttingPlanResult Plan(CuttingPlanRequest request, CancellationToken token = default) =>
Plan(Capture(request, token), token);
/// <summary>Worker-only planning uses owned values; live identities are never dereferenced.</summary>
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
{
if (token.IsCancellationRequested)
return new(CuttingPlanStatus.Cancelled);
if (snapshot == null)
return new(CuttingPlanStatus.InvalidInput);
if (snapshot.Failure is { } failure)
return new(failure, findings: snapshot.Findings);
try
{
var fixedFindings = new List<CuttingPlanFinding>();
foreach (var placement in snapshot.Placements)
{
token.ThrowIfCancellationRequested();
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState().Check(placement.Execution, null,
placement.SourceOrdinal + 1, token);
fixedFindings.AddRange(Map(snapshot, findings));
}
if (fixedFindings.Count != 0)
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: fixedFindings);
var search = FixedProgramSearch.Run(snapshot, token);
if (search.Status != CuttingPlanStatus.Ready)
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
return Replay(snapshot, search.Order, search.Expansions, token);
}
catch (OperationCanceledException)
{
return new(CuttingPlanStatus.Cancelled);
}
}
// Fresh checker and full sequence replay: no cached branch state is accepted as evidence.
internal static CuttingPlanResult Replay(CuttingPlanSnapshot snapshot, IReadOnlyList<int> order,
int expansions, CancellationToken token)
{
if (order.Count != snapshot.Placements.Count || order.Distinct().Count() != order.Count
|| order.Any(index => index < 0 || index >= snapshot.Placements.Count))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<PostVerificationFinding>();
foreach (var index in order)
{
token.ThrowIfCancellationRequested();
var placement = snapshot.Placements[index];
findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, token));
distance += placement.Execution.RapidDistanceFrom(position);
position = placement.Execution.DeparturePoint;
}
token.ThrowIfCancellationRequested();
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: Map(snapshot, findings), expansions: expansions);
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
}
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
{
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message);
});
}
@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>Bounded forward depth-first search, nearest feasible branch then source ordinal.</summary>
internal static class FixedProgramSearch
{
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<int> Order,
IReadOnlyList<PostVerificationFinding> Findings, int Expansions);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
var stack = new Stack<Frame>();
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
var rejected = new HashSet<PostVerificationFinding>();
var expansions = 0;
while (stack.Count != 0)
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
if (frame.Order.Length == snapshot.Placements.Count)
return new(CuttingPlanStatus.Ready, frame.Order, [], expansions);
if (frame.Next == frame.Candidates.Length)
{
stack.Pop();
continue;
}
if (expansions == snapshot.ExpansionBudget)
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.ToArray(), expansions);
var candidate = frame.Candidates[frame.Next++];
expansions++;
var placement = snapshot.Placements[candidate];
var checker = frame.Checker.Copy();
var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1, token);
if (findings.Count != 0)
{
rejected.UnionWith(findings);
continue;
}
stack.Push(Create([.. frame.Order, candidate], placement.Execution.DeparturePoint, checker));
}
return new(CuttingPlanStatus.ConstraintConflict, [], rejected.ToArray(), expansions);
Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker,
Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index))
.OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position))
.ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray());
}
private sealed class Frame(int[] order, Vector position, ReleasedContourState checker, int[] candidates)
{
internal int[] Order { get; } = order;
internal Vector Position { get; } = position;
internal ReleasedContourState Checker { get; } = checker;
internal int[] Candidates { get; } = candidates;
internal int Next { get; set; }
}
}
@@ -0,0 +1,156 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
internal int PreparationCount => attempts.Values.Sum();
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
{
this.spacing = spacing;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
if (pairs.TryGetValue(type.Index, out var found))
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
orientations[type.Index] = poses.Distinct().ToList();
}
}
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
{
var free = new PathsD { new PathD
{
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
new(work.Right, work.Top), new(work.Left, work.Top),
} };
foreach (var part in placed)
{
// Physical occupied material, not a reference-point region for any moving part.
// The catalog currently prepares solid outlines; future profile preparation owns holes.
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
2, NoFitCache.Precision);
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
FillRule.NonZero, NoFitCache.Precision);
}
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
}
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
return Array.Empty<Placed>();
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
cache[key] = result;
return result;
}
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
var plate = new Plate(new Size(rectangle.Width, rectangle.Length)) { PartSpacing = spacing };
try
{
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
var members = PlateFillService.FillItem("Default", plate, new NestItem
{
Drawing = drawing,
Quantity = quantity,
RotationStart = type.Part.Rotation.Start,
RotationEnd = type.Part.Rotation.End,
StepAngle = DrawingJobMapper.LegacyStep(type.Part.Rotation),
}, new Box(0, 0, rectangle.Length, rectangle.Width), null!, token);
token.ThrowIfCancellationRequested();
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Array.Empty<Placed>();
}
}
public void Dispose()
{
foreach (var drawing in drawings.Values)
BestFitCache.Invalidate(drawing);
drawings.Clear();
}
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
List<Orientation> orientations, double spacing, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (members.Count <= 2)
return Array.Empty<Placed>();
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
return Array.Empty<Placed>();
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|| !type.Part.Rotation.Allows(p.Rotation)))
return Array.Empty<Placed>();
for (var i = 0; i < poses.Length; i++)
for (var j = i + 1; j < poses.Length; j++)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
return Array.Empty<Placed>();
}
var result = new List<Placed>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
if (orientation == null)
{
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
if (orientation == null)
return Array.Empty<Placed>();
orientations.Add(orientation);
}
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
}
return result;
}
}
@@ -55,17 +55,30 @@ internal sealed class FrontierPacker
private readonly IReadOnlyList<PartType> types; private readonly IReadOnlyList<PartType> types;
private readonly NoFitCache nfps; private readonly NoFitCache nfps;
private readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs;
private readonly NestPlateStock stock; private readonly NestPlateStock stock;
private readonly PackAxis axis; private readonly PackAxis axis;
private readonly double beta; private readonly double beta;
private readonly Box work; private readonly Box work;
private readonly WorkCounter counter; private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter) public FrontierPacker(
IReadOnlyList<PartType> types,
NoFitCache nfps,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs,
NestPlateStock stock,
PackAxis axis,
double beta,
WorkCounter counter,
BlockCatalog? blocks = null
)
{ {
this.blocks = blocks;
this.counter = counter; this.counter = counter;
this.types = types; this.types = types;
this.nfps = nfps; this.nfps = nfps;
this.pairs = pairs;
this.stock = stock; this.stock = stock;
this.axis = axis; this.axis = axis;
this.beta = beta; this.beta = beta;
@@ -76,39 +89,102 @@ internal sealed class FrontierPacker
{ {
var left = remaining.ToArray(); var left = remaining.ToArray();
var states = new List<Region>(); var states = new List<Region>();
var byOrientation = new Dictionary<Orientation, Region>(ReferenceEqualityComparer.Instance);
bool Track(Orientation o, bool single)
{
if (byOrientation.ContainsKey(o))
return true;
if (!stock.Fits(o.Width, o.Height))
return false;
var region = new Region(o, work, single);
states.Add(region);
byOrientation[o] = region;
return true;
}
var offered = new List<PairState>();
foreach (var type in types) foreach (var type in types)
{ {
if (left[type.Index] <= 0) if (left[type.Index] <= 0)
continue; continue;
foreach (var o in type.Orientations) foreach (var o in type.Orientations)
if (stock.Fits(o.Width, o.Height)) Track(o, single: true);
states.Add(new Region(o, work)); if (left[type.Index] < 2 || !pairs.TryGetValue(type.Index, out var typePairs))
continue;
foreach (var pair in typePairs)
{
// Members missing from the catalog get regions too, but only for pair placement.
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
}
} }
var placed = new List<Placed>(); var placed = new List<Placed>();
var blockStates = new List<BlockState>();
var preparations = 0;
void PrepareBlocks()
{
if (blocks == null || preparations++ >= 2)
return;
var rectangles = BlockCatalog.Rectangles(work, placed, stock.PartSpacing);
foreach (var type in types.Where(t => left[t.Index] > 2)
.OrderByDescending(t => t.Area * left[t.Index]).ThenBy(t => t.Index).Take(4))
foreach (var rectangle in rectangles)
{
var members = blocks.Get(type, left[type.Index], rectangle, token);
if (members.Count <= 2)
continue;
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
if (!stock.Fits(width, height))
continue;
foreach (var member in members)
{
if (byOrientation.ContainsKey(member.Orientation))
continue;
if (!Track(member.Orientation, single: false))
break;
var region = byOrientation[member.Orientation];
foreach (var existing in placed)
region.Subtract(nfps.Get(existing.Orientation, member.Orientation), existing.X, existing.Y);
}
if (members.All(p => byOrientation.ContainsKey(p.Orientation)))
blockStates.Add(new BlockState(members, members.Select(p => byOrientation[p.Orientation]).ToArray()));
}
}
PrepareBlocks();
var partArea = 0.0; var partArea = 0.0;
var front = axis == PackAxis.X ? work.Left : work.Bottom; var front = axis == PackAxis.X ? work.Left : work.Bottom;
while (states.Count > 0) while (states.Count > 0)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var choice = Choose(states, front); var choice = Choose(states, offered, blockStates, front);
if (choice == null) if (choice == null)
break; break;
var (region, point) = choice.Value; var typeIndex = choice[0].Orientation.TypeIndex;
var part = new Placed(region.Orientation, point.x, point.y); foreach (var part in choice)
{
placed.Add(part); placed.Add(part);
var typeIndex = region.Orientation.TypeIndex;
partArea += types[typeIndex].Area; partArea += types[typeIndex].Area;
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top); front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
}
if (--left[typeIndex] == 0) left[typeIndex] -= choice.Count;
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
if (left[typeIndex] == 0)
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex); states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
if (left[typeIndex] < 2)
{
offered.RemoveAll(p => p.Pose.TypeIndex == typeIndex);
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex && !s.Single);
}
// Each surviving region loses the positions the new part now blocks. Regions are // Each surviving region loses the positions the new parts now block. Regions are
// independent, so they update in parallel without affecting determinism. // independent, so they update in parallel without affecting determinism.
var snapshot = states.ToArray(); var snapshot = states.ToArray();
foreach (var part in choice)
{
counter.Add(snapshot.Length); counter.Add(snapshot.Length);
Parallel.For( Parallel.For(
0, 0,
@@ -116,34 +192,42 @@ internal sealed class FrontierPacker
new ParallelOptions { CancellationToken = token }, new ParallelOptions { CancellationToken = token },
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y) i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
); );
}
states.RemoveAll(s => s.IsEmpty); states.RemoveAll(s => s.IsEmpty);
offered.RemoveAll(p => p.A.IsEmpty || p.B.IsEmpty);
if (offered.Count == 0 && blocks == null)
states.RemoveAll(s => !s.Single);
blockStates.RemoveAll(b => b.Regions.Any(r => r.IsEmpty));
PrepareBlocks();
} }
return new SheetFill(stock, placed, partArea); return new SheetFill(stock, placed, partArea);
} }
private (Region, PointD)? Choose(List<Region> states, double front) /// <summary>
/// The next placement: one part, or both members of a pair. Singles and pairs compete
/// under the same rule; a pair counts as one piece of twice the part area, and on a tie
/// the single (considered first) is kept.
/// </summary>
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, List<BlockState> blocks, double front)
{ {
Region? bestRegion = null; IReadOnlyList<Placed>? bestParts = null;
var bestPoint = default(PointD);
var bestFills = false; var bestFills = false;
var bestValue = double.PositiveInfinity; var bestValue = double.PositiveInfinity;
var bestSide = double.PositiveInfinity; var bestSide = double.PositiveInfinity;
var bestLead = double.PositiveInfinity; var bestLead = double.PositiveInfinity;
var bestPriority = int.MaxValue; var bestPriority = int.MaxValue;
foreach (var region in states) void Consider(Func<IReadOnlyList<Placed>> build, int typeIndex, double area, double advance, double side, double lead)
{ {
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead)) var priority = types[typeIndex].Part.Priority;
continue; if (priority > bestPriority)
var area = types[region.Orientation.TypeIndex].Area; return;
var priority = types[region.Orientation.TypeIndex].Part.Priority;
if (priority > bestPriority) continue;
var fills = advance <= Tie; var fills = advance <= Tie;
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area. // Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta); var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
var better = bestRegion == null var better = bestParts == null
|| priority < bestPriority || priority < bestPriority
|| (fills && !bestFills) || (fills && !bestFills)
|| ( || (
@@ -157,17 +241,83 @@ internal sealed class FrontierPacker
) )
); );
if (!better) if (!better)
continue; return;
bestRegion = region; bestParts = build();
bestPriority = priority; bestPriority = priority;
bestPoint = point;
bestFills = fills; bestFills = fills;
bestValue = value; bestValue = value;
bestSide = side; bestSide = side;
bestLead = lead; bestLead = lead;
} }
return bestRegion == null ? null : (bestRegion, bestPoint); foreach (var region in states)
{
if (!region.Single)
continue;
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var o = region.Orientation;
Consider(() => new[] { new Placed(o, point.x, point.y) }, o.TypeIndex, types[o.TypeIndex].Area, advance, side, lead);
}
foreach (var state in offered)
{
var pair = state.Pose;
if (!state.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
Consider(
() => new[] { new Placed(pair.A, point.x, point.y), new Placed(pair.B, point.x + pair.Dx, point.y + pair.Dy) },
pair.TypeIndex,
2 * types[pair.TypeIndex].Area,
advance,
side,
lead
);
}
foreach (var block in blocks)
{
if (!block.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
var typeIndex = block.Members[0].Orientation.TypeIndex;
Consider(() => block.Members.Select(p => new Placed(p.Orientation, p.X + point.x, p.Y + point.y)).ToArray(),
typeIndex, block.Members.Count * types[typeIndex].Area, advance, side, lead);
}
return bestParts;
}
private sealed class BlockState(IReadOnlyList<Placed> members, Region[] regions)
{
public IReadOnlyList<Placed> Members { get; } = members;
public Region[] Regions { get; } = regions;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
var free = Clipper.TranslatePaths(Regions[0].Free, -Members[0].X, -Members[0].Y);
var minX = double.NegativeInfinity;
var minY = double.NegativeInfinity;
var maxX = double.PositiveInfinity;
var maxY = double.PositiveInfinity;
for (var i = 0; i < Members.Count; i++)
{
var member = Members[i];
var region = Regions[i];
minX = System.Math.Max(minX, region.MinX - member.X);
minY = System.Math.Max(minY, region.MinY - member.Y);
maxX = System.Math.Min(maxX, region.MaxX - member.X);
maxY = System.Math.Min(maxY, region.MaxY - member.Y);
if (i > 0)
{
counter.Add(1);
free = Clipper.Intersect(free, Clipper.TranslatePaths(region.Free, -member.X, -member.Y),
FillRule.NonZero, NoFitCache.Precision);
}
}
return BestVertex(free, minX, minY, System.Math.Max(minX, maxX), System.Math.Max(minY, maxY),
(Members.Min(p => p.Left), Members.Min(p => p.Bottom), Members.Max(p => p.Right), Members.Max(p => p.Top)),
axis, front, out point, out advance, out side, out lead);
}
} }
/// <summary>Legal reference points for one orientation on this sheet.</summary> /// <summary>Legal reference points for one orientation on this sheet.</summary>
@@ -177,9 +327,10 @@ internal sealed class FrontierPacker
private PathsD free; private PathsD free;
private RectD bounds; private RectD bounds;
public Region(Orientation orientation, Box work) public Region(Orientation orientation, Box work, bool single)
{ {
Orientation = orientation; Orientation = orientation;
Single = single;
minX = work.Left - orientation.MinX; minX = work.Left - orientation.MinX;
maxX = work.Right - orientation.MaxX; maxX = work.Right - orientation.MaxX;
minY = work.Bottom - orientation.MinY; minY = work.Bottom - orientation.MinY;
@@ -203,6 +354,10 @@ internal sealed class FrontierPacker
} }
public Orientation Orientation { get; } public Orientation Orientation { get; }
/// <summary>False for a pair-only orientation: it is never placed on its own.</summary>
public bool Single { get; }
public bool IsEmpty => free.Count == 0; public bool IsEmpty => free.Count == 0;
public void Subtract(Nfp nfp, double dx, double dy) public void Subtract(Nfp nfp, double dx, double dy)
@@ -219,18 +374,54 @@ internal sealed class FrontierPacker
// Drop numerical dust; a sliver thinner than the precision grid is no real room. // Drop numerical dust; a sliver thinner than the precision grid is no real room.
free.RemoveAll(p => p.Count < 3); free.RemoveAll(p => p.Count < 3);
bounds = free.Count == 0 ? default : Clipper.GetBounds(free); bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
Version++;
} }
/// <summary>Changes whenever the free region does.</summary>
public int Version { get; private set; }
public PathsD Free => free;
public RectD Bounds => bounds;
public double MinX => minX;
public double MinY => minY;
public double MaxX => maxX;
public double MaxY => maxY;
/// <summary> /// <summary>
/// Best vertex of the free region: least front advance, then lowest cross-axis position, /// Best vertex of the free region: least front advance, then lowest cross-axis position,
/// then lowest leading edge. Vertices suffice because every score is linear in position. /// then lowest leading edge. Vertices suffice because every score is linear in position.
/// </summary> /// </summary>
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead) public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
{
var o = Orientation;
return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY),
axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>
/// Scores the vertices of <paramref name="free"/>, each clamped to the inner-fit box
/// [minX, maxX] x [minY, maxY], for a piece occupying <paramref name="box"/> around its
/// reference point.
/// </summary>
private static bool BestVertex(
PathsD free,
double minX,
double minY,
double maxX,
double maxY,
(double MinX, double MinY, double MaxX, double MaxY) box,
PackAxis axis,
double front,
out PointD point,
out double advance,
out double side,
out double lead
)
{ {
point = default; point = default;
advance = side = lead = double.PositiveInfinity; advance = side = lead = double.PositiveInfinity;
var found = false; var found = false;
var o = Orientation;
foreach (var path in free) foreach (var path in free)
foreach (var raw in path) foreach (var raw in path)
{ {
@@ -239,15 +430,15 @@ internal sealed class FrontierPacker
double reach, across, start; double reach, across, start;
if (axis == PackAxis.X) if (axis == PackAxis.X)
{ {
reach = x + o.MaxX; reach = x + box.MaxX;
across = y + o.MinY; across = y + box.MinY;
start = x + o.MinX; start = x + box.MinX;
} }
else else
{ {
reach = y + o.MaxY; reach = y + box.MaxY;
across = x + o.MinX; across = x + box.MinX;
start = y + o.MinY; start = y + box.MinY;
} }
var adv = System.Math.Max(0, reach - front); var adv = System.Math.Max(0, reach - front);
var better = !found var better = !found
@@ -263,5 +454,52 @@ internal sealed class FrontierPacker
} }
return found; return found;
} }
/// <summary>
/// Legal reference points for a pair: member A's free region intersected with member B's
/// moved back by the pair offset. Recomputed only after either member's region changes.
/// </summary>
private sealed class PairState(PairPose pose, Region a, Region b)
{
private int versionA = -1, versionB = -1;
private PathsD free = new();
public PairPose Pose { get; } = pose;
public Region A { get; } = a;
public Region B { get; } = b;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
if (versionA != A.Version || versionB != B.Version)
{
versionA = A.Version;
versionB = B.Version;
counter.Add(1);
free = Intersect();
}
// Clamp into both members' inner-fit boxes; they overlap whenever the pair fits.
var minX = System.Math.Max(A.MinX, B.MinX - Pose.Dx);
var maxX = System.Math.Max(minX, System.Math.Min(A.MaxX, B.MaxX - Pose.Dx));
var minY = System.Math.Max(A.MinY, B.MinY - Pose.Dy);
var maxY = System.Math.Max(minY, System.Math.Min(A.MaxY, B.MaxY - Pose.Dy));
return BestVertex(free, minX, minY, maxX, maxY, (Pose.MinX, Pose.MinY, Pose.MaxX, Pose.MaxY),
axis, front, out point, out advance, out side, out lead);
}
private PathsD Intersect()
{
if (A.IsEmpty || B.IsEmpty)
return new PathsD();
var a = A.Bounds;
var b = B.Bounds;
if (b.right - Pose.Dx < a.left || b.left - Pose.Dx > a.right
|| b.bottom - Pose.Dy < a.top || b.top - Pose.Dy > a.bottom)
return new PathsD();
var shifted = Clipper.TranslatePaths(B.Free, -Pose.Dx, -Pose.Dy);
var result = Clipper.Intersect(A.Free, shifted, FillRule.NonZero, NoFitCache.Precision);
result.RemoveAll(p => p.Count < 3);
return result;
}
} }
} }
@@ -1,7 +1,7 @@
#nullable enable #nullable enable
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System;
using System.Threading; using System.Threading;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
@@ -48,9 +48,10 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job); var types = PartCatalog.Build(job);
var solver = new Solver(job, types, progress, token); using var solver = new Solver(job, types, progress, token);
// Demand that no offered stock can hold in any allowed orientation is reported unplaced. // Pair-only orientations may fit stock even when the sampled single poses do not.
// Demand that neither a single nor a pair can fit is reported unplaced.
var demand = new int[types.Count]; var demand = new int[types.Count];
foreach (var type in types) foreach (var type in types)
{ {
@@ -58,7 +59,8 @@ public sealed class IrregularNestingEngine : INestingEngine
stock.Quantity != 0 stock.Quantity != 0
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height)) && type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
); );
demand[type.Index] = placeable ? type.Part.Quantity : 0; demand[type.Index] = placeable || solver.PairFits(type)
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
} }
Plan? best = null; Plan? best = null;
@@ -85,10 +87,26 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types, IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress, IProgress<NestJobProgress>? progress,
CancellationToken token CancellationToken token
) ) : IDisposable
{ {
public void Dispose()
{
foreach (var catalog in blocks.Values)
catalog.Dispose();
}
private const int MaxTail = 3; private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new(); private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private BlockCatalog BlocksFor(NestPlateStock stock)
{
var spacing = System.Math.Max(0, stock.PartSpacing);
if (!blocks.TryGetValue(spacing, out var found))
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock));
return found;
}
public WorkCounter Work { get; } = new(); public WorkCounter Work { get; } = new();
@@ -157,6 +175,30 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache; return cache;
} }
public bool PairFits(PartType type)
{
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
return false;
return job.Plates.Any(stock => stock.Quantity != 0
&& PairsFor(stock).TryGetValue(type.Index, out var candidates)
&& candidates.Any(pair => stock.Fits(pair.Width, pair.Height)));
}
/// <summary>Best-fit pairs for this stock's spacing, built once per solve.</summary>
private IReadOnlyDictionary<int, IReadOnlyList<PairPose>> PairsFor(NestPlateStock stock)
{
var clearance = System.Math.Max(0, stock.PartSpacing);
if (!pairs.TryGetValue(clearance, out var found))
{
// The best-fit plate filter only needs the largest sheet at this spacing;
// each packer still checks the pair against its own work area.
var sheets = job.Plates.Where(p => System.Math.Max(0, p.PartSpacing) == clearance).ToList();
pairs[clearance] = found = PairCatalog.Build(types, clearance,
sheets.Max(p => p.Size.Length), sheets.Max(p => p.Size.Width), token);
}
return found;
}
/// <summary> /// <summary>
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock /// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and /// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
@@ -199,7 +241,7 @@ public sealed class IrregularNestingEngine : INestingEngine
if (stock.Quantity is int available && used[stock.Id] >= available) if (stock.Quantity is int available && used[stock.Id] >= available)
continue; continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0)); progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work); var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token); var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0) if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill))); trials.Add((fill, NetArea(job.Options, fill)));
@@ -0,0 +1,207 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// Two copies of one part type placed together: member A at the pair's reference point and
/// member B at <see cref="Dx"/>, <see cref="Dy"/> from it.
/// </summary>
internal sealed class PairPose
{
public required Orientation A { get; init; }
public required Orientation B { get; init; }
public required double Dx { get; init; }
public required double Dy { get; init; }
public int TypeIndex => A.TypeIndex;
/// <summary>Box of both members around the pair's reference point (catalog-padded).</summary>
public double MinX => System.Math.Min(A.MinX, Dx + B.MinX);
public double MinY => System.Math.Min(A.MinY, Dy + B.MinY);
public double MaxX => System.Math.Max(A.MaxX, Dx + B.MaxX);
public double MaxY => System.Math.Max(A.MaxY, Dy + B.MaxY);
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
}
/// <summary>
/// Best-fit pairs offered to the packer for part types with two or more copies.
///
/// A pair is placed through the existing free regions: its reference point must be legal for
/// member A and, shifted by the pair offset, for member B (region A intersected with region B
/// moved back by the offset). No new no-fit polygons are built. The members' clearance from
/// each other comes from the best-fit search and is re-checked here once per pair with the
/// layout check at the job spacing; a pair that fails is never offered.
/// </summary>
internal static class PairCatalog
{
/// <summary>Best-fit results tried per type, in rank order.</summary>
private const int CandidatesPerType = 12;
/// <summary>Pairs kept per type; each one costs a region intersection per placement.</summary>
private const int PairsPerType = 2;
private const double AngleTolerance = 1e-6;
/// <summary>
/// Pairs for every type with quantity two or more, for one part spacing. A member rotation
/// missing from the catalog gets a pair-only orientation (indexed after the catalog's), so
/// both members always own cached footprints; such orientations are never offered as singles.
/// </summary>
/// <param name="sheetLength">Largest sheet X extent offered at this spacing.</param>
/// <param name="sheetWidth">Largest sheet Y extent offered at this spacing.</param>
public static IReadOnlyDictionary<int, IReadOnlyList<PairPose>> Build(
IReadOnlyList<PartType> types,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var result = new Dictionary<int, IReadOnlyList<PairPose>>();
foreach (var type in types)
{
token.ThrowIfCancellationRequested();
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
continue;
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
continue;
var pairs = BuildForType(type, geometry, spacing, sheetLength, sheetWidth, token);
if (pairs.Count > 0)
result[type.Index] = pairs;
}
return result;
}
private static List<PairPose> BuildForType(
PartType type,
JobPartGeometry geometry,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var drawing = new Drawing(type.Part.Id, DrawingJobMapper.ToProgram(type.Part.Geometry));
List<BestFitResult> fits;
try
{
fits = BestFitCache.GetOrCompute(drawing, sheetLength, sheetWidth, spacing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<PairPose>();
}
finally
{
// The drawing exists only for this call; release its cache entry now.
BestFitCache.Invalidate(drawing);
}
// Best-fit evaluates in parallel, so equal-area results arrive in any order. Sort on
// the candidate itself as well, so the same job always picks the same pairs.
var ranked = fits
.Where(f => f.Keep)
.OrderBy(f => f.RotatedArea)
.ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation)
.ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y)
.ThenBy(f => f.OptimalRotation)
.Take(CandidatesPerType);
var pairs = new List<PairPose>();
var extra = new List<Orientation>();
foreach (var fit in ranked)
{
token.ThrowIfCancellationRequested();
List<Part> members;
try
{
members = fit.BuildSourceParts(drawing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
continue;
}
if (members.Count != 2)
continue;
// The whole pair may also turn a quarter: offer it along each sheet axis.
foreach (var turn in new[] { 0.0, System.Math.PI / 2 })
{
var pair = Resolve(type, extra, geometry, members, turn, spacing);
if (pair != null && !pairs.Any(p => SamePair(p, pair)))
pairs.Add(pair);
if (pairs.Count >= PairsPerType)
return pairs;
}
}
return pairs;
}
internal static PairPose? Resolve(
PartType type,
List<Orientation> extra,
JobPartGeometry geometry,
List<Part> members,
double turn,
double spacing
)
{
// Turning a part about the origin turns its program and its location together,
// so the members keep their relative placement.
var poses = members
.Select(member =>
{
var location = member.Location.Rotate(turn);
return (Rotation: Angle.NormalizeRad(member.Rotation + turn), location.X, location.Y);
})
.ToArray();
if (!type.Part.Rotation.Allows(poses[0].Rotation) || !type.Part.Rotation.Allows(poses[1].Rotation))
return null;
var a = new NestJobPlacement(type.Part.Id, 0, poses[0].X, poses[0].Y, poses[0].Rotation);
var b = new NestJobPlacement(type.Part.Id, 1, poses[1].X, poses[1].Y, poses[1].Rotation);
if (!NestLayoutCheck.Clears(geometry, a, geometry, b, spacing))
return null;
var oa = Find(type, extra, poses[0].Rotation);
var ob = Find(type, extra, poses[1].Rotation);
if (oa == null || ob == null)
return null;
return new PairPose { A = oa, B = ob, Dx = poses[1].X - poses[0].X, Dy = poses[1].Y - poses[0].Y };
}
private static Orientation? Find(PartType type, List<Orientation> extra, double rotation)
{
foreach (var orientation in type.Orientations.Concat(extra))
if (SameAngle(orientation.Rotation, rotation))
return orientation;
var added = PartCatalog.CreateOrientation(type, type.Orientations.Count + extra.Count, rotation);
if (added != null)
extra.Add(added);
return added;
}
private static bool SamePair(PairPose first, PairPose second) =>
ReferenceEquals(first.A, second.A)
&& ReferenceEquals(first.B, second.B)
&& System.Math.Abs(first.Dx - second.Dx) < 1e-6
&& System.Math.Abs(first.Dy - second.Dy) < 1e-6;
private static bool SameAngle(double first, double second)
{
var difference = Angle.NormalizeRad(first - second);
return difference < AngleTolerance || Angle.TwoPI - difference < AngleTolerance;
}
}
@@ -25,7 +25,11 @@ internal sealed class Orientation
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary> /// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
public required double Tolerance { get; init; } public required double Tolerance { get; init; }
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary> /// <summary>
/// Outline bounds grown by the outline's actual flattening error, so they contain the true
/// perimeter: by <see cref="Tolerance"/> when arcs were flattened, not at all for line-only
/// outlines, whose vertices are exact. Footprints and NFPs still use <see cref="Tolerance"/>.
/// </summary>
public required double MinX { get; init; } public required double MinX { get; init; }
public required double MinY { get; init; } public required double MinY { get; init; }
public required double MaxX { get; init; } public required double MaxX { get; init; }
@@ -41,6 +45,9 @@ internal sealed class PartType
public required NestJobPart Part { get; init; } public required NestJobPart Part { get; init; }
public required double Area { get; init; } public required double Area { get; init; }
public required IReadOnlyList<Orientation> Orientations { get; init; } public required IReadOnlyList<Orientation> Orientations { get; init; }
/// <summary>Analytic perimeter the orientations were flattened from; null when unreadable.</summary>
public Shape? Perimeter { get; init; }
} }
/// <summary> /// <summary>
@@ -93,15 +100,36 @@ internal static class PartCatalog
var outline = Polygonize(perimeter, angle, tolerance); var outline = Polygonize(perimeter, angle, tolerance);
if (outline.Count < 3) if (outline.Count < 3)
continue; continue;
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance)); orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance, perimeter));
} }
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline)); var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations }); types.Add(new PartType
{
Index = index,
Part = part,
Area = area,
Orientations = orientations,
Perimeter = perimeter,
});
} }
return types; return types;
} }
/// <summary>
/// An extra pose of <paramref name="type"/> at <paramref name="angle"/>, flattened like the
/// catalog's own. <paramref name="index"/> must be unique within the type, because NFP
/// caches key on it.
/// </summary>
internal static Orientation? CreateOrientation(PartType type, int index, double angle)
{
if (type.Perimeter == null || type.Orientations.Count == 0)
return null;
var tolerance = type.Orientations[0].Tolerance;
var outline = Polygonize(type.Perimeter, angle, tolerance);
return outline.Count < 3 ? null : MakeOrientation(type.Index, index, angle, outline, tolerance, type.Perimeter);
}
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) => private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
JobPartGeometry.TryRead(geometry)?.Perimeter; JobPartGeometry.TryRead(geometry)?.Perimeter;
@@ -139,9 +167,21 @@ internal static class PartCatalog
return path; return path;
} }
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance) private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline,
double tolerance, Shape perimeter)
{ {
var bounds = Clipper.GetBounds(outline); var bounds = Clipper.GetBounds(outline);
// Curves retain flattening-error padding. For lines, use analytic endpoint bounds:
// polygon cleanup can discard short-edge chains and shrink the material's true extent.
var padding = tolerance;
if (perimeter.Entities.All(e => e.Type == EntityType.Line))
{
var nominal = (Shape)perimeter.Clone();
nominal.Rotate(angle);
var box = nominal.BoundingBox;
bounds = new RectD(box.Left, box.Bottom, box.Right, box.Top);
padding = 0;
}
return new Orientation return new Orientation
{ {
TypeIndex = typeIndex, TypeIndex = typeIndex,
@@ -149,10 +189,10 @@ internal static class PartCatalog
Rotation = angle, Rotation = angle,
Outline = outline, Outline = outline,
Tolerance = tolerance, Tolerance = tolerance,
MinX = bounds.left - tolerance, MinX = bounds.left - padding,
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y. MinY = bounds.top - padding, // Clipper RectD: top is the minimum Y.
MaxX = bounds.right + tolerance, MaxX = bounds.right + padding,
MaxY = bounds.bottom + tolerance, MaxY = bounds.bottom + padding,
}; };
} }
+444
View File
@@ -0,0 +1,444 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using OpenNest.Data;
using OpenNest.Geometry;
using OpenNest.IO;
using CncProgram = OpenNest.CNC.Program;
namespace OpenNest.Server.Tests;
/// <summary>
/// Production HTTP routes + real SQLite, driven through the desktop app's own
/// <see cref="RemoteNestRepository"/> and <see cref="NestSaveSession"/>.
/// Every test owns a fresh server and database.
/// </summary>
public sealed class NestApiTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
[Fact]
public async Task Host_UsesPrivateDatabase_NotDefaultPath()
{
var defaultPath = Path.GetFullPath(Path.Combine("data", "nests.db"));
Assert.False(File.Exists(defaultPath), "Precondition: no default database in the test directory.");
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(File.Exists(factory.DatabasePath));
Assert.False(File.Exists(defaultPath));
}
[Fact]
public async Task Health_WithOpenDatabase_ReturnsExistingSuccessShape()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("{\"status\":\"ok\"}", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Health_WithDisposedDatabase_Returns503WithoutStorageDetails()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
factory.Services.GetRequiredService<NestDatabase>().Dispose();
using var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal("{\"status\":\"unavailable\"}", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Create_ThenListAndDownload_ReturnsExactRecordAndArchive()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var archive = Archive(nest);
var session = new NestSaveSession();
var created = await session.SaveAsync(server.Repository, server.Url, nest, archive);
Assert.NotEqual(Guid.Empty, created.Id);
Assert.NotEqual(default, created.SavedAt);
Assert.Equal(archive.LongLength, created.FileSize);
AssertSameMetadata(Expected(nest, created, archive), created);
var listed = Assert.Single(await server.Repository.ListAsync());
AssertSameMetadata(created, listed);
AssertSameMetadata(created, (await server.Repository.GetMetadataAsync(created.Id))!);
var downloaded = await server.Repository.GetFileAsync(created.Id);
Assert.Equal(archive, downloaded);
AssertArchive(downloaded!, nest);
}
[Fact]
public async Task Save_AfterCreate_UpdatesSameRecordArchiveAndMetadata()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var session = new NestSaveSession();
var created = await session.SaveAsync(server.Repository, server.Url, nest, Archive(nest));
nest.Name = "Synthetic integration nest updated";
nest.Status = NestStatus.ToBeCut;
nest.Plates[0].Parts[0].Location = new Vector(4, 5);
var archive = Archive(nest);
var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive);
Assert.Equal(created.Id, updated.Id);
AssertSameMetadata(Expected(nest, updated, archive), updated);
var listed = Assert.Single(await server.Repository.ListAsync());
AssertSameMetadata(updated, listed);
var downloaded = await server.Repository.GetFileAsync(created.Id);
Assert.Equal(archive, downloaded);
AssertArchive(downloaded!, nest);
}
[Fact]
public async Task SaveCopy_CreatesDistinctRecordAndLeavesOriginal()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var session = new NestSaveSession();
var originalArchive = Archive(nest);
var original = await session.SaveAsync(server.Repository, server.Url, nest, originalArchive);
nest.Name = "Synthetic integration nest copy";
var copyArchive = Archive(nest);
var copy = await session.SaveAsync(server.Repository, server.Url, nest, copyArchive, saveCopy: true);
Assert.NotEqual(original.Id, copy.Id);
Assert.Equal(copy.Id, session.RemoteId);
var ids = (await server.Repository.ListAsync()).Select(r => r.Id).Order().ToArray();
Assert.Equal(new[] { original.Id, copy.Id }.Order().ToArray(), ids);
AssertSameMetadata(original, (await server.Repository.GetMetadataAsync(original.Id))!);
Assert.Equal(originalArchive, await server.Repository.GetFileAsync(original.Id));
Assert.Equal(copyArchive, await server.Repository.GetFileAsync(copy.Id));
}
[Fact]
public async Task MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var archive = Archive(nest);
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive);
var change = Clone(created);
change.Name = "Renamed by metadata update";
change.Status = NestStatus.HasBeenCut;
change.Comments = "Metadata-only change";
change.FileSize = 1; // Server-computed; must be ignored.
var updated = await server.Repository.UpdateMetadataAsync(created.Id, change);
Assert.Equal(created.Id, updated.Id);
Assert.Equal("Renamed by metadata update", updated.Name);
Assert.Equal(NestStatus.HasBeenCut, updated.Status);
Assert.Equal("Metadata-only change", updated.Comments);
Assert.Equal(archive.LongLength, updated.FileSize);
AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!);
Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id));
}
[Fact]
public async Task Delete_RemovesRecordAndArchive()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest));
await server.Repository.DeleteAsync(created.Id);
Assert.Empty(await server.Repository.ListAsync());
Assert.Null(await server.Repository.GetMetadataAsync(created.Id));
Assert.Null(await server.Repository.GetFileAsync(created.Id));
}
public static TheoryData<string, string> InvalidUploads()
{
var data = new TheoryData<string, string>();
foreach (var route in new[] { "create", "update" })
{
foreach (var body in new[]
{
"not multipart", "malformed multipart", "missing boundary", "missing metadata",
"invalid metadata JSON", "null metadata", "missing file", "empty file",
})
{
data.Add(route, body);
}
}
return data;
}
[Theory]
[MemberData(nameof(InvalidUploads))]
public async Task Upload_WithMissingOrMalformedParts_Returns400AndLeavesStoreUnchanged(string route, string body)
{
using var server = new TestServerSession();
var existing = await server.SeedAsync();
var before = await server.SnapshotAsync();
var method = route == "create" ? HttpMethod.Post : HttpMethod.Put;
var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file";
using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) };
using var response = await server.Client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(before, await server.SnapshotAsync());
}
[Theory]
[InlineData("{not-json")]
[InlineData("null")]
public async Task MetadataUpdate_WithInvalidJson_Returns400AndLeavesStoreUnchanged(string json)
{
using var server = new TestServerSession();
var existing = await server.SeedAsync();
var before = await server.SnapshotAsync();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(before, await server.SnapshotAsync());
}
[Fact]
public async Task UnknownId_Returns404OnEveryRouteWithoutCreatingRecords()
{
using var server = new TestServerSession();
var existing = await server.SeedAsync();
var before = await server.SnapshotAsync();
var unknown = Guid.NewGuid();
var record = Clone(existing);
record.Id = unknown;
var requests = new Func<HttpRequestMessage>[]
{
() => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}"),
() => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}/file"),
() => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/file")
{
Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), new byte[] { 1, 2, 3 }),
},
() => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/metadata")
{
Content = new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8,
"application/json"),
},
() => new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{unknown}"),
};
foreach (var create in requests)
{
using var request = create();
using var response = await server.Client.SendAsync(request);
Assert.True(response.StatusCode == HttpStatusCode.NotFound,
$"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404.");
}
Assert.Equal(before, await server.SnapshotAsync());
}
[Fact]
public void Startup_WithUnusableDatabasePath_Fails()
{
var directory = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
try
{
// A regular file where the data directory should be: the database cannot be created.
var blocker = Path.Combine(directory, "not-a-directory");
File.WriteAllText(blocker, "");
using var factory = new ServerFactory(Path.Combine(blocker, "nests.db"));
var error = Record.Exception(() => factory.CreateClient());
Assert.NotNull(error);
Assert.False(File.Exists(Path.Combine(blocker, "nests.db")));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task Dispose_RemovesTemporaryDatabaseDirectory()
{
string directory;
using (var server = new TestServerSession())
{
await server.SeedAsync();
directory = server.Factory.DirectoryPath;
Assert.True(File.Exists(server.Factory.DatabasePath));
}
Assert.False(Directory.Exists(directory));
}
private static HttpContent InvalidUploadContent(string body)
{
var metadata = JsonSerializer.Serialize(new NestRecord { Name = "Synthetic rejected upload" }, JsonOptions);
switch (body)
{
case "not multipart":
return new StringContent(metadata, Encoding.UTF8, "application/json");
case "malformed multipart":
var content = new StringContent("--other-boundary\r\nnot a multipart body");
content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=expected-boundary");
return content;
case "missing boundary":
var unbounded = Multipart(metadata, new byte[] { 1 });
unbounded.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data");
return unbounded;
case "missing metadata":
return Multipart(null, new byte[] { 1 });
case "invalid metadata JSON":
return Multipart("{not-json", new byte[] { 1 });
case "null metadata":
return Multipart("null", new byte[] { 1 });
case "missing file":
return Multipart(metadata, null);
case "empty file":
return Multipart(metadata, Array.Empty<byte>());
default:
throw new ArgumentOutOfRangeException(nameof(body), body, null);
}
}
private static MultipartFormDataContent Multipart(string? metadata, byte[]? file)
{
var content = new MultipartFormDataContent();
if (metadata is not null)
content.Add(new StringContent(metadata, Encoding.UTF8, "application/json"), "metadata");
if (file is not null)
content.Add(new ByteArrayContent(file), "file", "synthetic.nest");
return content;
}
private static NestRecord Expected(Nest nest, NestRecord saved, byte[] archive)
{
var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength);
expected.SavedAt = saved.SavedAt;
return expected;
}
private static NestRecord Clone(NestRecord record) =>
JsonSerializer.Deserialize<NestRecord>(JsonSerializer.Serialize(record, JsonOptions), JsonOptions)!;
private static void AssertSameMetadata(NestRecord expected, NestRecord actual) =>
Assert.Equal(JsonSerializer.Serialize(expected, JsonOptions), JsonSerializer.Serialize(actual, JsonOptions));
private static void AssertArchive(byte[] archive, Nest expected)
{
using var stream = new MemoryStream(archive, writable: false);
var reader = new NestReader(stream);
var nest = reader.Read();
Assert.Empty(reader.Warnings);
Assert.Equal(expected.Name, nest.Name);
Assert.Equal(expected.Status, nest.Status);
var part = Assert.Single(Assert.Single(nest.Plates).Parts);
Assert.Equal(expected.Plates[0].Parts[0].Location.X, part.Location.X);
Assert.Equal(expected.Plates[0].Parts[0].Location.Y, part.Location.Y);
Assert.Equal(18, Assert.Single(nest.Drawings).Area);
}
internal static Nest SyntheticNest()
{
var program = new CncProgram();
program.MoveTo(0, 0);
program.LineTo(6, 0);
program.LineTo(6, 3);
program.LineTo(0, 3);
program.LineTo(0, 0);
var drawing = new Drawing("Synthetic rectangle", program);
drawing.Quantity.Required = 1;
var nest = new Nest("Synthetic integration nest")
{
Customer = "Synthetic test customer",
DateCreated = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified),
Material = new Material("Synthetic alloy", "test-grade", 0.25),
Thickness = 0.25,
Status = NestStatus.Quote,
Notes = "Neutral generated fixture; no customer data",
MadeBy = "Synthetic test author",
Units = Units.Inches,
};
nest.Drawings.Add(drawing);
var plate = new Plate(new Size(20, 30)) { Quantity = 1 };
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
nest.Plates.Add(plate);
return nest;
}
internal static byte[] Archive(Nest nest)
{
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
/// <summary>One in-memory server, its HTTP client, and the real storage client over it.</summary>
private sealed class TestServerSession : IDisposable
{
public TestServerSession()
{
Factory = new ServerFactory();
Client = Factory.CreateClient();
Url = Client.BaseAddress!.ToString();
Repository = new RemoteNestRepository(Client, Url);
}
public ServerFactory Factory { get; }
public HttpClient Client { get; }
public string Url { get; }
public RemoteNestRepository Repository { get; }
public async Task<NestRecord> SeedAsync()
{
var nest = SyntheticNest();
return await new NestSaveSession().SaveAsync(Repository, Url, nest, Archive(nest));
}
/// <summary>Every record's metadata plus its archive hash, in id order.</summary>
public async Task<string> SnapshotAsync()
{
var lines = new List<string>();
foreach (var record in (await Repository.ListAsync()).OrderBy(r => r.Id))
{
var file = await Repository.GetFileAsync(record.Id);
Assert.NotNull(file);
lines.Add(JsonSerializer.Serialize(record, JsonOptions) + " " + Convert.ToHexString(SHA256.HashData(file!)));
}
return string.Join("\n", lines);
}
public void Dispose()
{
Repository.Dispose();
Client.Dispose();
Factory.Dispose();
}
}
}
@@ -0,0 +1,252 @@
using System.Net;
using System.Reflection;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestDatabaseConcurrencyTests
{
// Code-level ownership gate: hold the database's monitor and observe a dedicated
// synchronous caller waiting on it, not a sleep or a stress-run timing assumption.
[Theory]
[InlineData("List")]
[InlineData("Query")]
[InlineData("Get")]
[InlineData("GetFile")]
[InlineData("Insert")]
[InlineData("UpdateFile")]
[InlineData("UpdateMetadata")]
[InlineData("Delete")]
[InlineData("Health")]
[InlineData("Dispose")]
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath);
var id = Guid.NewGuid();
var record = new NestRecord { Name = "Monitor ownership fixture" };
database.Insert(id, record, new byte[] { 1, 2, 3 });
var field = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
var sync = field.GetValue(database)!;
Assert.NotNull(sync);
var completed = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
var worker = new Thread(() =>
{
completed.SetResult(Record.Exception(() =>
{
switch (operation)
{
case "List": database.List(); break;
case "Query": database.Query(new NestQuery { Search = "fixture" }); break;
case "Get": database.Get(id); break;
case "GetFile": database.GetFile(id); break;
case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break;
case "UpdateFile": database.Update(id, record, new byte[] { 5 }); break;
case "UpdateMetadata": database.Update(id, record, null); break;
case "Delete": database.Delete(id); break;
case "Health": Assert.True(database.IsHealthy()); break;
case "Dispose": database.Dispose(); break;
default: throw new InvalidOperationException($"Unknown test operation: {operation}");
}
}));
})
{ IsBackground = true };
var observedWaiting = false;
var completedWhileHeld = false;
lock (sync)
{
worker.Start();
observedWaiting = SpinWait.SpinUntil(() => completed.Task.IsCompleted ||
(worker.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10));
completedWhileHeld = completed.Task.IsCompleted;
}
Assert.True(worker.Join(TimeSpan.FromSeconds(10)), "Operation did not finish after releasing the monitor.");
Assert.True(observedWaiting, "Caller never reached the operation within the watchdog.");
Assert.False(completedWhileHeld, operation + " bypassed the shared monitor.");
Assert.Null(await completed.Task);
}
[Fact]
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath);
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(database)!;
var connection = (SqliteConnection)typeof(NestDatabase)
.GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!;
var steps = 0;
var unownedSteps = 0;
// Observe every native VM step, including iteration of readers and nested
// write readbacks. No assertion/exception is thrown across the native callback.
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1, _ =>
{
steps++;
if (!Monitor.IsEntered(sync))
unownedSteps++;
return 0;
}, null);
try
{
var id = Guid.NewGuid();
var record = Fixture(0, 0);
Action[] operations =
[
() => database.Insert(id, record, new byte[] { 1 }),
() => database.List(),
() => database.Query(new NestQuery { Search = "Synthetic", Limit = 1 }),
() => database.Get(id),
() => database.GetFile(id),
() => database.Update(id, record, new byte[] { 2 }),
() => database.Update(id, record, null),
() => Assert.True(database.IsHealthy()),
() => database.Delete(id),
];
foreach (var operation in operations)
{
var before = steps;
operation();
Assert.True(steps > before, "Operation did not execute the observed SQLite VM.");
Assert.Equal(0, unownedSteps);
}
}
finally
{
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 0, null, null);
}
}
[Fact]
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath);
using var start = new Barrier(4);
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
{
Assert.True(start.SignalAndWait(TimeSpan.FromSeconds(10)));
var records = new List<(NestRecord Record, byte[] File)>();
for (var index = 0; index < 12; index++)
{
var id = Guid.NewGuid();
var record = Fixture(lane, index);
var file = new byte[] { (byte)lane, (byte)index, 0, 255 };
var saved = database.Insert(id, record, file);
AssertStoredRecord(record, id, file, saved);
AssertRecord(saved, database.Get(id)!);
Assert.Contains(database.List(), r => r.Id == id);
Assert.Equal(file, database.GetFile(id));
record.Comments = "Archive updated";
file = file.Concat(new byte[] { 42 }).ToArray();
saved = database.Update(id, record, file)!;
AssertStoredRecord(record, id, file, saved);
record.Comments = "Metadata only";
saved = database.Update(id, record, null)!;
AssertStoredRecord(record, id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, database.GetFile(id));
var transient = Guid.NewGuid();
database.Insert(transient, record, new byte[] { 9 });
Assert.True(database.Delete(transient));
Assert.Null(database.Get(transient));
records.Add((saved, file));
}
return records;
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray();
var expected = (await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(60))).SelectMany(r => r).ToArray();
Assert.Equal(expected.Select(r => r.Record.Id).Order(), database.List().Select(r => r.Id).Order());
foreach (var (record, file) in expected)
{
AssertRecord(record, database.Get(record.Id)!);
Assert.Equal(file, database.GetFile(record.Id));
}
}
[Fact]
public async Task Concurrency_MultipleHttpClients_PreserveMetadataAndSyntheticArchives()
{
using var factory = new ServerFactory();
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var workers = Enumerable.Range(0, 8).Select(async lane =>
{
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
await start.Task;
var records = new List<(NestRecord Record, byte[] File)>();
for (var index = 0; index < 4; index++)
{
var nest = NestApiTests.SyntheticNest();
nest.Name = $"Synthetic concurrent nest {lane}-{index}";
var session = new NestSaveSession();
var file = NestApiTests.Archive(nest);
var saved = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
cancellationToken: watchdog.Token);
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, saved);
AssertRecord(saved, (await repository.GetMetadataAsync(saved.Id, watchdog.Token))!);
Assert.Contains(await repository.ListAsync(watchdog.Token), r => r.Id == saved.Id);
nest.Notes = $"Archive change {lane}-{index}";
file = NestApiTests.Archive(nest);
var updated = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
cancellationToken: watchdog.Token);
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, updated);
updated.Comments = $"Metadata only {lane}-{index}";
saved = await repository.UpdateMetadataAsync(updated.Id, updated, watchdog.Token);
AssertStoredRecord(updated, updated.Id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, await repository.GetFileAsync(saved.Id, watchdog.Token));
using var health = await client.GetAsync("/healthz", watchdog.Token);
Assert.Equal(HttpStatusCode.OK, health.StatusCode);
records.Add((saved, file));
}
return records;
}).ToArray();
start.SetResult();
var expected = (await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(70))).SelectMany(r => r).ToArray();
using var verifierClient = factory.CreateClient();
using var verifier = new RemoteNestRepository(verifierClient, verifierClient.BaseAddress!.ToString());
var listed = await verifier.ListAsync(watchdog.Token);
Assert.Equal(expected.Select(r => r.Record.Id).Order(), listed.Select(r => r.Id).Order());
foreach (var (record, file) in expected)
{
AssertRecord(record, listed.Single(r => r.Id == record.Id));
AssertRecord(record, (await verifier.GetMetadataAsync(record.Id, watchdog.Token))!);
Assert.Equal(file, await verifier.GetFileAsync(record.Id, watchdog.Token));
}
}
private static NestRecord Fixture(int lane, int index) => new()
{
Name = $"Synthetic database nest {lane}-{index}",
Customer = "Synthetic customer",
DateCreated = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified),
DateModified = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified),
Material = "Synthetic alloy",
Thickness = 0.25,
Status = NestStatus.ToBeCut,
PlateCount = lane + 1,
PartCount = index + 1,
Comments = "Initial archive",
MadeBy = "Synthetic author",
};
private static void AssertStoredRecord(NestRecord metadata, Guid id, byte[] file, NestRecord actual)
{
var expected = JsonSerializer.Deserialize<NestRecord>(JsonSerializer.Serialize(metadata))!;
expected.Id = id;
expected.FileSize = file.LongLength;
expected.SavedAt = actual.SavedAt;
Assert.NotEqual(default, actual.SavedAt);
AssertRecord(expected, actual);
}
private static void AssertRecord(NestRecord expected, NestRecord actual) =>
Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual));
}
+380
View File
@@ -0,0 +1,380 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
/// <summary>
/// Bounded, SQL-filtered browsing (<c>GET /api/nests/query</c>) through the production
/// routes, real SQLite and the desktop's <see cref="RemoteNestRepository"/>.
/// </summary>
public sealed class NestQueryTests : IDisposable
{
private readonly ServerFactory _factory = new();
private readonly HttpClient _client;
private readonly RemoteNestRepository _repository;
private readonly NestDatabase _database;
public NestQueryTests()
{
_client = _factory.CreateClient();
_repository = new RemoteNestRepository(_client, _client.BaseAddress!.ToString());
_database = _factory.Services.GetRequiredService<NestDatabase>();
}
[Fact]
public async Task Query_WithSelectiveSearch_ReturnsOnlyMatchesAndTheirTotal()
{
var seeded = Seed(24);
var expected = Ordered(seeded.Where(r => r.Customer == "Beta Works"));
var page = await _repository.QueryAsync(new NestQuery { Search = " BETA ", Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Length, page.Total);
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
Assert.All(page.Items, r => AssertSameMetadata(seeded.Single(s => s.Id == r.Id), r));
}
[Fact]
public async Task Query_WithSelectiveSearch_DoesNotTransferUnmatchedRows()
{
Seed(24);
var body = await _client.GetStringAsync("/api/nests/query?search=beta&limit=500");
Assert.Contains("Beta Works", body);
Assert.DoesNotContain("Alpha Fabrication", body);
Assert.DoesNotContain("Gamma 50% Shop", body);
Assert.DoesNotContain("Delta_Under", body);
Assert.DoesNotContain("\"file\"", body);
}
[Fact]
public async Task Query_WithNoMatch_ReturnsEmptyPageAndZeroTotal()
{
Seed(10);
var page = await _repository.QueryAsync(new NestQuery { Search = "no such synthetic text" });
Assert.Empty(page.Items);
Assert.Equal(0, page.Total);
}
[Fact]
public async Task Query_Pages_PartitionTheOrderedSetExactly()
{
var expected = Ordered(Seed(25)).Select(r => r.Id).ToArray();
var collected = new List<Guid>();
for (var offset = 0; offset < expected.Length; offset += 7)
{
var page = await _repository.QueryAsync(new NestQuery { Offset = offset, Limit = 7 });
Assert.Equal(25, page.Total);
Assert.Equal(offset, page.Offset);
Assert.Equal(7, page.Limit);
Assert.Equal(System.Math.Min(7, expected.Length - offset), page.Items.Count);
collected.AddRange(page.Items.Select(r => r.Id));
}
Assert.Equal(expected, collected);
}
[Theory]
[InlineData(25)]
[InlineData(1000)]
[InlineData(int.MaxValue)]
public async Task Query_OffsetPastTheEnd_ReturnsNoItemsWithTheTrueTotal(int offset)
{
Seed(25);
var page = await _repository.QueryAsync(new NestQuery { Offset = offset });
Assert.Empty(page.Items);
Assert.Equal(25, page.Total);
}
[Fact]
public async Task Query_DefaultLimit_BoundsAnUnfilteredRequest()
{
Seed(NestQuery.DefaultLimit + 5);
var page = await _repository.QueryAsync(new NestQuery());
var raw = JsonDocument.Parse(await _client.GetStringAsync("/api/nests/query"));
Assert.Equal(NestQuery.DefaultLimit, page.Items.Count);
Assert.Equal(NestQuery.DefaultLimit + 5, page.Total);
Assert.Equal(NestQuery.DefaultLimit, raw.RootElement.GetProperty("items").GetArrayLength());
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Query_BlankSearch_EqualsTheUnfilteredOrder(string search)
{
var expected = Ordered(Seed(12)).Select(r => r.Id);
var page = await _repository.QueryAsync(new NestQuery { Search = search });
Assert.Equal(expected, page.Items.Select(r => r.Id));
Assert.Equal(12, page.Total);
}
[Theory]
[InlineData("0%", "Gamma 50% Shop")]
[InlineData("_", "Delta_Under")]
[InlineData(@"\", @"Path C:\jobs")]
public async Task Query_TreatsLikeWildcardsAndEscapeAsLiteralText(string search, string expectedText)
{
var seeded = Seed(24);
var expected = Ordered(seeded.Where(r => r.Customer == expectedText || r.Comments == expectedText));
Assert.NotEmpty(expected);
Assert.True(expected.Length < seeded.Count);
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
Assert.Equal(expected.Length, page.Total);
}
[Theory]
[InlineData("to be cut", new[] { NestStatus.ToBeCut })]
[InlineData("HAS BEEN", new[] { NestStatus.HasBeenCut })]
[InlineData("tobecut", new[] { NestStatus.ToBeCut })]
[InlineData("cut", new[] { NestStatus.ToBeCut, NestStatus.HasBeenCut })]
[InlineData("quote", new[] { NestStatus.Quote })]
public async Task Query_MatchesStoredAndDisplayedStatusNames(string search, NestStatus[] statuses)
{
var seeded = Seed(24);
var expected = Ordered(seeded.Where(r => statuses.Contains(r.Status)));
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
}
[Theory]
[InlineData("material", "Synthetic steel")]
[InlineData("madeBy", "Synthetic operator B")]
[InlineData("comments", "rush")]
[InlineData("name", "browse nest 07")]
public async Task Query_MatchesEachTextColumn(string column, string search)
{
var seeded = Seed(24);
Func<NestRecord, string> value = column switch
{
"material" => r => r.Material,
"madeBy" => r => r.MadeBy,
"comments" => r => r.Comments,
_ => r => r.Name,
};
var expected = Ordered(seeded.Where(r => value(r).Contains(search, StringComparison.OrdinalIgnoreCase)));
Assert.NotEmpty(expected);
Assert.True(expected.Length < seeded.Count);
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
}
[Fact]
public async Task Query_DoesNotMatchDatesOrNumbers()
{
Seed(12);
var thickness = await _repository.QueryAsync(new NestQuery { Search = "0.25" });
var date = await _repository.QueryAsync(new NestQuery { Search = "2026-01" });
Assert.Equal(0, thickness.Total);
Assert.Equal(0, date.Total);
}
[Theory]
[InlineData("limit=0")]
[InlineData("limit=501")]
[InlineData("limit=abc")]
[InlineData("limit=")]
[InlineData("limit=-1")]
[InlineData("limit=1.5")]
[InlineData("offset=-1")]
[InlineData("offset=99999999999")]
[InlineData("limit=1&limit=2")]
[InlineData("serach=beta")]
[InlineData("status=quote")]
[InlineData("search=Alpha%00not-present")]
[InlineData("search=%00")]
public async Task Query_InvalidParameters_Return400WithoutItems(string queryString)
{
Seed(3);
using var response = await _client.GetAsync("/api/nests/query?" + queryString);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.DoesNotContain("items", body);
Assert.DoesNotContain("Synthetic browse nest", body);
}
[Fact]
public async Task Query_OverLongSearch_Returns400()
{
Seed(3);
var search = new string('x', NestQuery.MaxSearchLength + 1);
using var response = await _client.GetAsync("/api/nests/query?search=" + search);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
public static TheoryData<NestSortField, bool> SortCases()
{
var data = new TheoryData<NestSortField, bool>();
foreach (var field in Enum.GetValues<NestSortField>())
{
data.Add(field, false);
data.Add(field, true);
}
return data;
}
[Theory]
[MemberData(nameof(SortCases))]
public async Task Query_SortsEachAllowlistedColumnAcrossPages(NestSortField field, bool descending)
{
var expected = Sorted(Seed(25), field, descending).Select(r => r.Id).ToArray();
var collected = new List<Guid>();
for (var offset = 0; offset < expected.Length; offset += 6)
{
var page = await _repository.QueryAsync(
new NestQuery { Sort = field, Descending = descending, Offset = offset, Limit = 6 });
collected.AddRange(page.Items.Select(r => r.Id));
}
Assert.Equal(expected, collected);
}
[Fact]
public async Task Query_SortAndOrderNamesAreCaseInsensitive()
{
var expected = Sorted(Seed(12), NestSortField.PlateCount, descending: false).Select(r => r.Id);
var body = await _client.GetStringAsync("/api/nests/query?sort=PLATECOUNT&order=ASC");
var ids = JsonDocument.Parse(body).RootElement.GetProperty("items").EnumerateArray()
.Select(item => item.GetProperty("id").GetGuid());
Assert.Equal(expected, ids);
}
[Theory]
[InlineData("sort=unknown")]
[InlineData("sort=1")]
[InlineData("sort=-1")]
[InlineData("sort=")]
[InlineData("sort=saved_at")]
[InlineData("sort=savedAt&sort=name")]
[InlineData("order=up")]
[InlineData("order=")]
[InlineData("order=asc&order=desc")]
public async Task Query_InvalidSortOrOrder_Returns400WithoutItems(string queryString)
{
Seed(3);
using var response = await _client.GetAsync("/api/nests/query?" + queryString);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.DoesNotContain("items", body);
}
[Fact]
public async Task List_RemainsTheUnboundedFullEnumeration()
{
var seeded = Seed(NestQuery.DefaultLimit + 5);
var listed = await _repository.ListAsync();
Assert.Equal(seeded.Select(r => r.Id).Order(), listed.Select(r => r.Id).Order());
}
public void Dispose()
{
_repository.Dispose();
_client.Dispose();
_factory.Dispose();
}
/// <summary>Synthetic records with rotating customers/statuses; returns the stored rows.</summary>
private List<NestRecord> Seed(int count)
{
string[] customers = { "Alpha Fabrication", "Beta Works", "Gamma 50% Shop", "Delta_Under" };
var statuses = Enum.GetValues<NestStatus>();
var stored = new List<NestRecord>();
for (var index = 0; index < count; index++)
{
var record = new NestRecord
{
// Alternate case so text sorts must fold case rather than order by code point.
Name = (index % 2 == 0 ? "Synthetic" : "synthetic") + $" browse nest {index:D2}",
Customer = customers[index % customers.Length],
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",
Thickness = 0.25 + index % 3 * 0.125,
Status = statuses[index % statuses.Length],
PlateCount = 1 + index % 4,
PartCount = 3 + index,
Comments = index % 5 == 0 ? "rush" : index % 7 == 1 ? @"Path C:\jobs" : "",
MadeBy = index % 2 == 0 ? "Synthetic operator A" : "Synthetic operator B",
};
stored.Add(_database.Insert(Guid.NewGuid(), record, new byte[3 + index % 4]));
}
return stored;
}
/// <summary>Independent oracle for the browse order: newest saved first, then id.</summary>
private static NestRecord[] Ordered(IEnumerable<NestRecord> records) =>
records
.OrderByDescending(r => r.SavedAt)
.ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal)
.ToArray();
/// <summary>Independent oracle for allowlisted sorts: ASCII case-folded text, then id.</summary>
private static IEnumerable<NestRecord> Sorted(IEnumerable<NestRecord> records, NestSortField field, bool descending)
{
static Comparison<NestRecord> Text(Func<NestRecord, string> value) =>
(a, b) => string.CompareOrdinal(value(a).ToLowerInvariant(), value(b).ToLowerInvariant());
Comparison<NestRecord> compare = field switch
{
NestSortField.SavedAt => (a, b) => a.SavedAt.CompareTo(b.SavedAt),
NestSortField.Name => Text(r => r.Name),
NestSortField.Customer => Text(r => r.Customer),
NestSortField.Status => Text(r => r.Status.ToString()),
NestSortField.Material => Text(r => r.Material),
NestSortField.DateCreated => (a, b) => a.DateCreated.CompareTo(b.DateCreated),
NestSortField.DateModified => (a, b) => a.DateModified.CompareTo(b.DateModified),
NestSortField.Thickness => (a, b) => a.Thickness.CompareTo(b.Thickness),
NestSortField.PlateCount => (a, b) => a.PlateCount.CompareTo(b.PlateCount),
NestSortField.PartCount => (a, b) => a.PartCount.CompareTo(b.PartCount),
NestSortField.MadeBy => Text(r => r.MadeBy),
NestSortField.Comments => Text(r => r.Comments),
NestSortField.FileSize => (a, b) => a.FileSize.CompareTo(b.FileSize),
_ => throw new ArgumentOutOfRangeException(nameof(field)),
};
var sorted = records.ToList();
sorted.Sort((a, b) =>
{
var result = compare(a, b);
if (result == 0)
result = string.CompareOrdinal(a.Id.ToString(), b.Id.ToString());
return descending ? -result : result;
});
return sorted;
}
private static void AssertSameMetadata(NestRecord expected, NestRecord actual) =>
Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual));
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Server\OpenNest.Server.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<!-- IO only writes/reads synthetic .nest archives in tests; the server never references it. -->
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+53
View File
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace OpenNest.Server.Tests;
/// <summary>
/// Hosts the production routes in memory against a private, disposable SQLite file.
/// The production <see cref="NestDatabase"/> registration is replaced before anything
/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path.
/// Disposing the factory disposes the host (and its database) before deleting the directory.
/// </summary>
public sealed class ServerFactory : WebApplicationFactory<global::Program>
{
private bool _cleanedUp;
public ServerFactory(string? databasePath = null)
{
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DirectoryPath);
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
}
/// <summary>Private temporary directory owned by this factory.</summary>
public string DirectoryPath { get; }
public string DatabasePath { get; }
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<NestDatabase>();
services.AddSingleton(_ => new NestDatabase(DatabasePath));
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing || _cleanedUp)
return;
_cleanedUp = true;
// Pooled connections would keep the file open on Windows after the host disposed it.
SqliteConnection.ClearAllPools();
if (Directory.Exists(DirectoryPath))
Directory.Delete(DirectoryPath, recursive: true);
}
}
+40 -5
View File
@@ -1,23 +1,58 @@
# Build from the repository root: docker build -f OpenNest.Server/Dockerfile -t opennest-server . # Build from the repository root: docker build -f OpenNest.Server/Dockerfile -t opennest-server .
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build # Release builds pass --build-arg VERSION=X.Y.Z and SOURCE_REVISION=<full commit SHA>;
# the defaults mark an unversioned development build. Base images may be pinned by digest.
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:8.0
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:8.0
FROM ${SDK_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/ COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/
COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/ COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/
COPY OpenNest.Core/OpenNest.Core.csproj OpenNest.Core/
RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj
COPY OpenNest.Server/ OpenNest.Server/ COPY OpenNest.Server/ OpenNest.Server/
COPY OpenNest.Data/ OpenNest.Data/ COPY OpenNest.Data/ OpenNest.Data/
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore COPY OpenNest.Core/ OpenNest.Core/
ARG VERSION=0.0.0-dev
ARG SOURCE_REVISION=unknown
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore \
-p:Version="$VERSION" -p:SourceRevisionId="$SOURCE_REVISION"
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime FROM ${RUNTIME_IMAGE} AS runtime
# curl is installed only for the HEALTHCHECK below.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Runtime files stay root-owned and read-only to the app user.
COPY --from=build /app . COPY --from=build /app .
# SQLite database + .nest blobs live here; mount a volume at this path. # SQLite database + .nest blobs live here; mount a volume at this path. The directory
VOLUME /app/data # belongs to the base image's non-root app user (APP_UID 1654), so a fresh named volume
# inherits that ownership. Existing root-owned data needs a one-time scoped chown.
RUN mkdir -p /app/data && chown "$APP_UID:$APP_UID" /app/data && chmod 0750 /app/data
ENV OPENNEST_DB=/app/data/nests.db ENV OPENNEST_DB=/app/data/nests.db
# Listen on the explicit 8090 URL; clear the base image's 8080 port default.
ENV ASPNETCORE_URLS=http://+:8090 ENV ASPNETCORE_URLS=http://+:8090
ENV ASPNETCORE_HTTP_PORTS=
EXPOSE 8090 EXPOSE 8090
USER $APP_UID
VOLUME /app/data
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=2s --retries=3 \
CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8090/healthz"]
ARG VERSION=0.0.0-dev
ARG SOURCE_REVISION=unknown
ARG RUNTIME_IMAGE
LABEL org.opencontainers.image.title="OpenNest.Server" \
org.opencontainers.image.description="OpenNest nest-storage HTTP service (SQLite)" \
org.opencontainers.image.source="https://github.com/ajisaacs/OpenNest" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="$VERSION" \
org.opencontainers.image.revision="$SOURCE_REVISION" \
org.opencontainers.image.base.name="$RUNTIME_IMAGE"
ENTRYPOINT ["dotnet", "OpenNest.Server.dll"] ENTRYPOINT ["dotnet", "OpenNest.Server.dll"]
+124 -1
View File
@@ -11,6 +11,9 @@ namespace OpenNest.Server;
/// </summary> /// </summary>
public sealed class NestDatabase : IDisposable public sealed class NestDatabase : IDisposable
{ {
// One reentrant monitor owns each complete connection operation, including
// readers, nested Get readbacks, health checks, and disposal.
private readonly object _sync = new();
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
public NestDatabase(string databasePath) public NestDatabase(string databasePath)
@@ -45,6 +48,8 @@ public sealed class NestDatabase : IDisposable
} }
public IReadOnlyList<NestRecord> List() public IReadOnlyList<NestRecord> List()
{
lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = command.CommandText =
@@ -55,8 +60,11 @@ public sealed class NestDatabase : IDisposable
records.Add(ReadRecord(reader)); records.Add(ReadRecord(reader));
return records; return records;
} }
}
public NestRecord? Get(Guid id) public NestRecord? Get(Guid id)
{
lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id"; command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
@@ -64,8 +72,50 @@ public sealed class NestDatabase : IDisposable
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
return reader.Read() ? ReadRecord(reader) : null; return reader.Read() ? ReadRecord(reader) : null;
} }
}
/// <summary>
/// One bounded page of matching metadata plus the total match count. The count and
/// the page are read in the same monitor hold, so they describe one state.
/// </summary>
public NestPage Query(NestQuery query)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
if (error is not null)
throw new ArgumentException(error, nameof(query));
var search = query.NormalizedSearch;
var where = search.Length == 0 ? "" : SearchPredicate;
lock (_sync)
{
using var count = _connection.CreateCommand();
count.CommandText = $"SELECT COUNT(*) FROM nests {where}";
AddSearchParameter(count, search);
var total = checked((int)(long)count.ExecuteScalar()!);
using var command = _connection.CreateCommand();
var direction = query.Descending ? "DESC" : "ASC";
command.CommandText = $"""
SELECT {RecordColumns} FROM nests {where}
ORDER BY {SortColumn(query.Sort)} {direction}, id {direction}
LIMIT $limit OFFSET $offset
""";
AddSearchParameter(command, search);
command.Parameters.AddWithValue("$limit", query.Limit);
command.Parameters.AddWithValue("$offset", query.Offset);
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read())
records.Add(ReadRecord(reader));
return new NestPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit };
}
}
public byte[]? GetFile(Guid id) public byte[]? GetFile(Guid id)
{
lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = "SELECT file FROM nests WHERE id = $id"; command.CommandText = "SELECT file FROM nests WHERE id = $id";
@@ -73,8 +123,11 @@ public sealed class NestDatabase : IDisposable
var value = command.ExecuteScalar(); var value = command.ExecuteScalar();
return value is byte[] bytes ? bytes : null; return value is byte[] bytes ? bytes : null;
} }
}
public NestRecord Insert(Guid id, NestRecord record, byte[] file) public NestRecord Insert(Guid id, NestRecord record, byte[] file)
{
lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = """ command.CommandText = """
@@ -89,8 +142,11 @@ public sealed class NestDatabase : IDisposable
command.ExecuteNonQuery(); command.ExecuteNonQuery();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist."); return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
} }
}
public NestRecord? Update(Guid id, NestRecord record, byte[]? file) public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
{
lock (_sync)
{ {
if (Get(id) is null) if (Get(id) is null)
return null; return null;
@@ -110,16 +166,42 @@ public sealed class NestDatabase : IDisposable
command.ExecuteNonQuery(); command.ExecuteNonQuery();
return Get(id); return Get(id);
} }
}
public bool Delete(Guid id) public bool Delete(Guid id)
{
lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM nests WHERE id = $id"; command.CommandText = "DELETE FROM nests WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteNonQuery() > 0; return command.ExecuteNonQuery() > 0;
} }
}
public void Dispose() => _connection.Dispose(); /// <summary>Checks the live connection without exposing storage error details.</summary>
public bool IsHealthy()
{
lock (_sync)
{
try
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT 1";
return command.ExecuteScalar() is long value && value == 1;
}
catch (Exception ex) when (ex is SqliteException or InvalidOperationException)
{
return false;
}
}
}
public void Dispose()
{
lock (_sync)
_connection.Dispose();
}
private void Execute(string sql) private void Execute(string sql)
{ {
@@ -133,6 +215,47 @@ public sealed class NestDatabase : IDisposable
status, plateCount, partCount, comments, madeBy, fileSize, savedAt status, plateCount, partCount, comments, madeBy, fileSize, savedAt
"""; """;
// Case-insensitive (ASCII) substring over the text columns and the status's stored
// and display names. The search text is always a bound, escaped LIKE parameter.
private const string SearchPredicate = """
WHERE name LIKE $pattern ESCAPE '\'
OR customer LIKE $pattern ESCAPE '\'
OR material LIKE $pattern ESCAPE '\'
OR madeBy LIKE $pattern ESCAPE '\'
OR comments LIKE $pattern ESCAPE '\'
OR status LIKE $pattern ESCAPE '\'
OR (CASE status WHEN 'ToBeCut' THEN 'To Be Cut' WHEN 'HasBeenCut' THEN 'Has Been Cut'
ELSE status END) LIKE $pattern ESCAPE '\'
""";
// Fixed allowlist: request text never reaches the ORDER BY clause.
private static string SortColumn(NestSortField field) => field switch
{
NestSortField.SavedAt => "savedAt",
NestSortField.Name => "name COLLATE NOCASE",
NestSortField.Customer => "customer COLLATE NOCASE",
NestSortField.Status => "status COLLATE NOCASE",
NestSortField.Material => "material COLLATE NOCASE",
NestSortField.DateCreated => "dateCreated",
NestSortField.DateModified => "dateModified",
NestSortField.Thickness => "thickness",
NestSortField.PlateCount => "plateCount",
NestSortField.PartCount => "partCount",
NestSortField.MadeBy => "madeBy COLLATE NOCASE",
NestSortField.Comments => "comments COLLATE NOCASE",
NestSortField.FileSize => "fileSize",
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
};
private static void AddSearchParameter(SqliteCommand command, string search)
{
if (search.Length == 0)
return;
var escaped = search.Replace(@"\", @"\\").Replace("%", @"\%").Replace("_", @"\_");
command.Parameters.AddWithValue("$pattern", "%" + escaped + "%");
}
private static void AddRecordParameters( private static void AddRecordParameters(
SqliteCommand command, Guid id, NestRecord record, byte[]? file, bool updateFile) SqliteCommand command, Guid id, NestRecord record, byte[]? file, bool updateFile)
{ {
+96
View File
@@ -0,0 +1,96 @@
using System.Globalization;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Parses <c>GET /api/nests/query</c> parameters. Unknown, repeated, non-integer or
/// out-of-range values are rejected rather than ignored or clamped, so a request
/// can never silently receive a broader result than it asked for.
/// </summary>
internal static class NestQueryRequest
{
private static readonly string[] Keys = { "search", "sort", "order", "offset", "limit" };
public static bool TryParse(IQueryCollection values, out NestQuery query, out string error)
{
query = new NestQuery();
foreach (var key in values.Keys)
{
if (!Keys.Contains(key, StringComparer.OrdinalIgnoreCase))
{
error = $"Unknown query parameter '{key}'.";
return false;
}
if (values[key].Count > 1)
{
error = $"Query parameter '{key}' was given more than once.";
return false;
}
}
var sort = NestSortField.SavedAt;
if (values.TryGetValue("sort", out var sortText)
&& !TrySortField(sortText.ToString(), out sort))
{
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<NestSortField>().Select(CamelCase))}.";
return false;
}
var descending = true;
if (values.TryGetValue("order", out var orderText))
{
if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase))
{
descending = false;
}
else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase))
{
error = "order must be asc or desc.";
return false;
}
}
var offset = 0;
var limit = NestQuery.DefaultLimit;
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
return false;
query = new NestQuery
{
Search = values["search"].ToString(),
Sort = sort,
Descending = descending,
Offset = offset,
Limit = limit,
};
error = query.GetValidationError() ?? "";
return error.Length == 0;
}
// Names only: Enum.TryParse would also accept numbers and undefined values.
private static bool TrySortField(string text, out NestSortField field)
{
field = NestSortField.SavedAt;
return text.Length > 0
&& text.All(char.IsAsciiLetter)
&& Enum.TryParse(text, ignoreCase: true, out field)
&& Enum.IsDefined(field);
}
private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
{
error = "";
if (!values.TryGetValue(key, out var text))
return true;
if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value))
return true;
error = $"{key} must be a non-negative integer.";
return false;
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.31" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+29 -3
View File
@@ -25,15 +25,27 @@ var databasePath =
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1] args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
?? Environment.GetEnvironmentVariable("OPENNEST_DB") ?? Environment.GetEnvironmentVariable("OPENNEST_DB")
?? Path.Combine("data", "nests.db"); ?? Path.Combine("data", "nests.db");
builder.Services.AddSingleton(new NestDatabase(databasePath)); builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090. // Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build(); var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); // Open the database now so an unusable data path fails startup, not the first request.
app.Services.GetRequiredService<NestDatabase>();
app.MapGet("/healthz", (NestDatabase db) => db.IsHealthy()
? Results.Ok(new { status = "ok" })
: Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable));
// Full enumeration (backup manifests, restore checks and the container smoke).
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List())); app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
// Bounded, SQL-filtered page for browsing.
app.MapGet("/api/nests/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query))
: Results.BadRequest(error));
app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) => app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound()); db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound());
@@ -91,7 +103,18 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip
if (!http.Request.HasFormContentType) if (!http.Request.HasFormContentType)
return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts.")); return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts."));
var form = await http.Request.ReadFormAsync(); IFormCollection form;
try
{
form = await http.Request.ReadFormAsync();
}
catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException))
{
// A truncated or malformed multipart body is a client error. BadHttpRequestException
// (for example, an oversized body) keeps the status code Kestrel assigns it.
return (null, null, Results.BadRequest("Malformed multipart body."));
}
var metadataPart = form["metadata"].FirstOrDefault(); var metadataPart = form["metadata"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(metadataPart)) if (string.IsNullOrWhiteSpace(metadataPart))
return (null, null, Results.BadRequest("Missing 'metadata' part.")); return (null, null, Results.BadRequest("Missing 'metadata' part."));
@@ -127,3 +150,6 @@ internal static class MultipartJson
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
}; };
} }
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
public partial class Program { }
+13
View File
@@ -0,0 +1,13 @@
# Copy to a local, untracked env file next to compose.server.yaml and edit it.
# Never put credentials in this file or the Compose file.
# A tested image version or digest, for example ghcr.io/ajisaacs/opennest-server@sha256:<digest>.
OPENNEST_SERVER_IMAGE=ghcr.io/ajisaacs/opennest-server:REPLACE-WITH-TESTED-VERSION
# Loopback for a local test. For shop PCs, use this host's trusted LAN address and
# confirm the firewall admits only trusted clients; the service has no authentication.
OPENNEST_BIND_ADDRESS=127.0.0.1
OPENNEST_HOST_PORT=8090
# Docker volume holding nests.db; change only to switch to a verified restore.
OPENNEST_DATA_VOLUME=opennest-data
@@ -0,0 +1,413 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanServiceTests
{
[Fact]
public void FixedPrograms_OriginalOrderCrossesA_ReorderedWholeProgramsDoNot()
{
var parts = Fixture();
var original = Analyze(parts);
Assert.Equal(new int?[] { 2, 3 }, original.Findings.Select(f => f.PartNumber));
Assert.All(original.Findings, crossing =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, crossing.Kind);
Assert.Equal(1, crossing.OtherPartNumber);
});
var reordered = Analyze(parts[1], parts[0], parts[2]);
Assert.Empty(reordered.Findings);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Plan_ReordersWholeFixedPrograms_AndIndependentlyReplaysEveryPlacement(bool locked)
{
var parts = Fixture();
foreach (var part in parts)
part.LeadInsLocked = locked;
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(parts.Length, result.ProposedOrder.Select(p => p.SourcePart).Distinct().Count());
Assert.Empty(result.Findings);
Assert.Empty(Analyze(result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
Assert.Equal(new Vector(12.25, 2), result.ProposedOrder[^1].Execution.DeparturePoint);
unchanged();
}
[Fact]
public void Plan_BacktracksRatherThanReturningUnsafeNearestOrder()
{
var parts = Fixture();
var start = new Vector(8.25, 2); // A is nearest but no continuation after A can reach B safely.
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.True(result.Expansions > parts.Length);
Assert.Empty(AnalyzeFrom(start, result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Plan_LockedFixedInternalCrossingIsConflictWithExactSourceIdentity(Mode mode)
{
var part = Rectangle("holes", 100, 50, 20, 20);
var hole = new Program();
hole.MoveTo(0.5, 0);
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var placed = new Program();
placed.SubPrograms[-7] = hole;
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = -7 });
placed.MoveTo(8, 5);
placed.MoveTo(2, 5);
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = -7 });
placed.Mode = mode;
Assert.True(part.RestoreLeadInProgram(placed, true));
var parts = new[] { Fixture()[0], part };
var unchanged = Unchanged(parts);
Assert.Contains(Analyze(part).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
Assert.All(result.Findings, finding =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, finding.Kind);
Assert.Equal(1, finding.SourceOrdinal);
Assert.Equal(1, finding.OtherSourceOrdinal);
Assert.Same(part, finding.SourcePart);
Assert.Same(part, finding.OtherSourcePart);
});
unchanged();
}
[Theory]
[InlineData("recursive")]
[InlineData("missing-call")]
[InlineData("null-code")]
[InlineData("nonfinite")]
[InlineData("bad-arc")]
[InlineData("empty")]
[InlineData("motionless")]
[InlineData("null-codes")]
public void Plan_MalformedOrEmptyCuttingStateNeverReady(string fault)
{
var part = Fixture()[0];
var program = part.Program;
switch (fault)
{
case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break;
case "missing-call": program.Codes.Add(new SubProgramCall()); break;
case "null-code": program.Codes.Add(null!); break;
case "nonfinite": program.MoveTo(double.NaN, 0); break;
case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break;
case "empty": program.Codes.Clear(); break;
case "motionless": program.Codes.Clear(); program.MoveTo(1, 1); break;
case "null-codes": program.Codes = null!; break;
}
var beforeCodes = program.Codes?.ToArray();
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.InvalidInput, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.Same(program, part.Program);
Assert.Equal(beforeCodes, program.Codes?.ToArray());
}
[Theory]
[InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")]
[InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
{
var part = Fixture()[0];
var expected = CuttingPlanStatus.UnsupportedGeometry;
switch (fault)
{
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
case "missing-lead":
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
}
var unchanged = Unchanged([part]);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(expected, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
unchanged();
}
[Fact]
public void Plan_BudgetExhaustionHasNoFallbackOrMutation()
{
var parts = Fixture();
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 1));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Equal(1, result.Expansions);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
unchanged();
}
[Fact]
public void Plan_CancelledCaptureAndWorkerHaveNoMutation()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Equal(CuttingPlanStatus.Cancelled,
CuttingPlanService.Plan(new CuttingPlanRequest(parts), cancellation.Token).Status);
Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancellation.Token).Status);
unchanged();
}
[Fact]
public void Snapshot_OwnsMotionsPosesLocksAndOrder_NotLiveProgramsOrSettings()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var ownedEnds = snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)).ToArray();
foreach (var part in parts)
{
part.Program.Codes.Clear();
part.BaseDrawing.Program.Codes.Clear();
part.Location = new Vector(999, 999);
part.CuttingParameters.TabsEnabled = true;
part.LeadInsLocked = true;
}
Array.Reverse(parts);
var result = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(ownedEnds, snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)));
Assert.Equal(new Vector(4, 0), snapshot.Placements[0].Location);
Assert.All(snapshot.Placements, p => Assert.False(p.LeadInsLocked));
Assert.True(((ICollection<FixedProgramPlacement>)snapshot.Placements).IsReadOnly);
Assert.True(((ICollection<ExecutionMotion>)snapshot.Placements[0].Execution.Motions).IsReadOnly);
Assert.True(((ICollection<FixedProgramPlacement>)result.ProposedOrder).IsReadOnly);
}
[Fact]
public void Plan_DeterministicReferenceIdentity_NotDrawingNamesOrNameEquality()
{
var parts = Fixture();
foreach (var part in parts)
part.BaseDrawing.Name = "same";
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
for (var repeat = 0; repeat < 5; repeat++)
Assert.Equal(new[] { parts[1], parts[0], parts[2] },
CuttingPlanService.Plan(snapshot).ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest([parts[0], parts[0]])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest([])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 0)).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(double.NaN, 0))).Status);
}
[Fact]
public void ExplicitStart_UsesActualLeadoutDeparture_AndDefaultRemainsZero()
{
var part = Fixture()[1];
var defaultResult = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
var explicitResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(2.25, 2)));
Assert.Equal(CuttingPlanStatus.Ready, defaultResult.Status);
Assert.Equal(CuttingPlanStatus.Ready, explicitResult.Status);
Assert.Equal(Vector.Zero.DistanceTo(new Vector(2.25, 2)), defaultResult.RapidDistance, 10);
Assert.Equal(0, explicitResult.RapidDistance);
Assert.Equal(new Vector(2.25, 2), explicitResult.ProposedOrder[0].Execution.DeparturePoint);
Assert.Empty(Analyze(part).Findings);
Assert.Empty(AnalyzeFrom(Vector.Zero, part).Findings);
}
[Theory]
[InlineData("unsafe")]
[InlineData("duplicate")]
[InlineData("omitted")]
[InlineData("unknown")]
public void IndependentReplay_RefusesUnsafeOrIncompleteProposals(string fault)
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
var order = fault switch
{
"unsafe" => new[] { 0, 1, 2 },
"duplicate" => [1, 0, 0],
"omitted" => [1, 0],
_ => [1, 0, 3]
};
var result = CuttingPlanService.Replay(snapshot, order, 0, default);
Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput,
result.Status);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
if (fault == "unsafe")
{
Assert.Equal(new int?[] { 1, 2 }, result.Findings.Select(f => f.SourceOrdinal));
Assert.All(result.Findings, f => Assert.Same(parts[0], f.OtherSourcePart));
}
unchanged();
}
[Fact]
public void EqualRapidDistance_TiesKeepSourceOrdinal()
{
var parts = new[] { Rectangle("same", 10, -3, 2, 2), Rectangle("same", 10, 1, 2, 2) };
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(parts, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Empty(Analyze(parts).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void OwnedReader_SharedSignedSubcallsPreserveTrueLeadoutDepartureAndOwnership(Mode mode)
{
var sub = new Program();
sub.MoveTo(1.25, 0);
sub.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
sub.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
sub.Codes.Add(new LinearMove(1.25, 0) { Layer = LayerType.Leadout });
sub.Mode = mode;
var program = new Program();
program.SubPrograms[-7] = sub;
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(5, 5) });
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(15, 5) });
var before = sub.ToString();
var owned = ExecutionMotionReader.Read(program, new Vector(100, 50), Vector.Zero, default);
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Equal(new Vector(106.25, 55), owned.Motions[3].End);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
sub.Codes.Clear();
program.Codes.Clear();
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
Assert.NotEmpty(before);
}
[Fact]
public void Check_CopiedReleasedStateDoesNotLeakBranchObstacles()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var parent = new ReleasedContourState();
var branch = parent.Copy();
Assert.Empty(branch.Check(snapshot.Placements[0].Execution, Vector.Zero, 1));
Assert.NotEmpty(branch.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
Assert.Empty(parent.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
}
private static Action Unchanged(Part[] parts)
{
var order = parts.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var codeRefs = parts.Select(p => p.Program.Codes.ToArray()).ToArray();
var text = parts.Select(p => p.Program.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var rotations = parts.Select(p => p.Rotation).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var parameters = parts.Select(p => p.CuttingParameters).ToArray();
var locks = parts.Select(p => p.LeadInsLocked).ToArray();
var manual = parts.Select(p => p.HasManualLeadIns).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var subs = parts.SelectMany(p => p.Program.Codes.OfType<SubProgramCall>())
.Select(call => (call, call.Program, text: call.Program.ToString(), call.Offset, call.Rotation, call.Id)).ToArray();
return () =>
{
Assert.Equal(order, parts);
for (var index = 0; index < parts.Length; index++)
{
var part = parts[index];
Assert.Same(programs[index], part.Program);
Assert.Equal(codeRefs[index], part.Program.Codes);
Assert.Equal(text[index], part.Program.ToString());
Assert.Equal(locations[index], part.Location);
Assert.Equal(rotations[index], part.Rotation);
Assert.Same(bounds[index], part.BoundingBox);
Assert.Same(parameters[index], part.CuttingParameters);
Assert.Equal(locks[index], part.LeadInsLocked);
Assert.Equal(manual[index], part.HasManualLeadIns);
Assert.Equal(quantities[index], part.BaseDrawing.Quantity.Nested);
}
foreach (var sub in subs)
{
Assert.Same(sub.Program, sub.call.Program);
Assert.Equal(sub.text, sub.call.Program.ToString());
Assert.Equal(sub.Offset, sub.call.Offset);
Assert.Equal(sub.Rotation, sub.call.Rotation);
Assert.Equal(sub.Id, sub.call.Id);
}
};
}
private static Part[] Fixture() =>
[
Rectangle("A", 4, 0, 4, 4),
Rectangle("B", 0, 1, 2, 2),
Rectangle("C", 10, 1, 2, 2)
];
private static Part Rectangle(string name, double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing(name, clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
part.CuttingParameters = new CuttingParameters();
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
// Independent replay uses private placements/drawings, never an event-wired plate
// around the source drawings (which would change their quantity accounting).
private static PostVerificationReport Analyze(params Part[] parts) => AnalyzeFrom(Vector.Zero, parts);
private static PostVerificationReport AnalyzeFrom(Vector start, params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
foreach (var source in parts)
{
var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
return PostVerificationAnalyzer.Analyze(nest, start);
}
}
@@ -0,0 +1,325 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class NestBrowseSessionTests
{
[Fact]
public async Task Refresh_RequestsTheFirstBoundedPageNewestSavedFirst()
{
var repository = new PagedRepository(25);
using var session = new NestBrowseSession(repository, pageSize: 10);
Assert.True(await session.RefreshAsync());
var query = Assert.Single(repository.Queries);
Assert.Equal("", query.Search);
Assert.Equal(NestSortField.SavedAt, query.Sort);
Assert.True(query.Descending);
Assert.Equal(0, query.Offset);
Assert.Equal(10, query.Limit);
Assert.Equal(10, session.Page!.Items.Count);
Assert.Equal("Showing 1-10 of 25 nests", session.Summary);
}
[Fact]
public async Task NextAndPrevious_StayWithinTheMatches()
{
var repository = new PagedRepository(25);
using var session = new NestBrowseSession(repository, pageSize: 10);
await session.RefreshAsync();
Assert.False(session.CanGoPrevious);
Assert.True(session.CanGoNext);
Assert.True(await session.NextPageAsync());
Assert.Equal("Showing 11-20 of 25 nests", session.Summary);
Assert.True(await session.NextPageAsync());
Assert.Equal("Showing 21-25 of 25 nests", session.Summary);
Assert.False(session.CanGoNext);
Assert.True(session.CanGoPrevious);
var sent = repository.Queries.Count;
Assert.False(await session.NextPageAsync());
Assert.Equal(sent, repository.Queries.Count);
Assert.True(await session.PreviousPageAsync());
Assert.Equal(10, repository.Queries[^1].Offset);
Assert.Equal("Showing 11-20 of 25 nests", session.Summary);
}
[Fact]
public async Task SetSearch_TrimsTextAndReturnsToTheFirstPage()
{
var repository = new PagedRepository(25);
using var session = new NestBrowseSession(repository, pageSize: 10);
await session.RefreshAsync();
await session.NextPageAsync();
await session.SetSearchAsync(" beta works ");
var query = repository.Queries[^1];
Assert.Equal("beta works", query.Search);
Assert.Equal(0, query.Offset);
Assert.Equal("beta works", session.Search);
}
[Fact]
public async Task SortBy_NewColumnAscends_SameColumnReverses_AndResetsTheOffset()
{
var repository = new PagedRepository(25);
using var session = new NestBrowseSession(repository, pageSize: 10);
await session.RefreshAsync();
await session.NextPageAsync();
await session.SortByAsync(NestSortField.Customer);
Assert.Equal((NestSortField.Customer, false, 0), Last(repository));
await session.SortByAsync(NestSortField.Customer);
Assert.Equal((NestSortField.Customer, true, 0), Last(repository));
await session.SortByAsync(NestSortField.Thickness);
Assert.Equal((NestSortField.Thickness, false, 0), Last(repository));
await session.SortByAsync(NestSortField.SavedAt);
await session.SortByAsync(NestSortField.SavedAt);
Assert.Equal((NestSortField.SavedAt, true, 0), Last(repository));
}
[Fact]
public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerResult()
{
var repository = new ControlledRepository();
using var session = new NestBrowseSession(repository);
var older = session.SetSearchAsync("a");
var newer = session.SetSearchAsync("ab");
var newerPage = Page("ab result");
repository.Requests[1].Reply.SetResult(newerPage);
Assert.True(await newer);
repository.Requests[0].Reply.SetResult(Page("a result"));
Assert.False(await older);
Assert.Same(newerPage, session.Page);
Assert.True(repository.Requests[0].Token.IsCancellationRequested);
Assert.False(repository.Requests[1].Token.IsCancellationRequested);
}
[Fact]
public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsThePageAndPropagates()
{
var repository = new ControlledRepository();
using var session = new NestBrowseSession(repository);
var older = session.SetSearchAsync("a");
var newer = session.SetSearchAsync("ab");
repository.Requests[0].Reply.SetException(new IOException("superseded failure"));
Assert.False(await older);
repository.Requests[1].Reply.SetResult(Page("ab result"));
Assert.True(await newer);
Assert.NotNull(session.Page);
var failing = session.RefreshAsync();
repository.Requests[2].Reply.SetException(new IOException("server offline"));
var error = await Assert.ThrowsAsync<IOException>(() => failing);
Assert.Equal("server offline", error.Message);
Assert.Null(session.Page);
Assert.Equal("", session.Summary);
}
[Fact]
public async Task Refresh_AfterTheLastPageWasEmptied_StepsBackToTheNewLastPage()
{
var repository = new PagedRepository(21);
using var session = new NestBrowseSession(repository, pageSize: 10);
await session.RefreshAsync();
await session.NextPageAsync();
await session.NextPageAsync();
Assert.Equal("Showing 21-21 of 21 nests", session.Summary);
repository.RemoveLast();
Assert.True(await session.RefreshAsync());
Assert.Equal(new[] { 20, 10 }, repository.Queries.TakeLast(2).Select(q => q.Offset));
Assert.Equal(10, session.Offset);
Assert.Equal("Showing 11-20 of 20 nests", session.Summary);
}
[Fact]
public async Task SupersededRequest_CompletingSynchronouslyOnCancellation_IsNotReportedAsTheLatestFailure()
{
var repository = new ControlledRepository { CompleteOnCancellation = true };
using var session = new NestBrowseSession(repository);
var older = session.SetSearchAsync("a");
var newer = session.SetSearchAsync("ab");
Assert.False(await older);
repository.Requests[1].Reply.SetResult(Page("ab result"));
Assert.True(await newer);
Assert.Equal("ab result", Assert.Single(session.Page!.Items).Name);
}
[Fact]
public async Task IsLoading_FollowsOnlyTheLatestRequest()
{
var repository = new ControlledRepository();
using var session = new NestBrowseSession(repository, pageSize: 1);
Assert.False(session.IsLoading);
// A superseded request finishing first leaves the latest one loading.
var first = session.SetSearchAsync("a");
var second = session.SetSearchAsync("ab");
repository.Requests[0].Reply.SetResult(Page("a result"));
Assert.False(await first);
Assert.True(session.IsLoading);
repository.Requests[1].Reply.SetResult(Page("ab result"));
Assert.True(await second);
Assert.False(session.IsLoading);
// The latest request finishing first ends loading although a superseded one is still out.
var third = session.SetSearchAsync("abc");
var fourth = session.SetSearchAsync("abcd");
var record = new NestRecord { Id = Guid.NewGuid(), Name = "abcd result" };
repository.Requests[3].Reply.SetResult(new NestPage { Items = new[] { record }, Total = 3, Limit = 1 });
Assert.True(await fourth);
Assert.False(session.IsLoading);
Assert.True(session.CanGoNext);
repository.Requests[2].Reply.SetResult(Page("abc result"));
Assert.False(await third);
Assert.False(session.IsLoading);
var failing = session.RefreshAsync();
Assert.True(session.IsLoading);
repository.Requests[4].Reply.SetException(new IOException("offline"));
await Assert.ThrowsAsync<IOException>(() => failing);
Assert.False(session.IsLoading);
}
[Fact]
public async Task EachRequestsCancellationSource_IsReleasedWhenThatRequestEnds()
{
var repository = new ControlledRepository();
var session = new NestBrowseSession(repository);
var older = session.SetSearchAsync("a");
var newer = session.SetSearchAsync("ab");
repository.Requests[1].Reply.SetResult(Page("ab"));
await newer;
Assert.Throws<ObjectDisposedException>(() => repository.Requests[1].Token.WaitHandle);
repository.Requests[0].Reply.SetResult(Page("a"));
await older;
Assert.Throws<ObjectDisposedException>(() => repository.Requests[0].Token.WaitHandle);
var pending = session.RefreshAsync();
session.Dispose();
repository.Requests[2].Reply.SetResult(Page("late"));
await pending;
Assert.Throws<ObjectDisposedException>(() => repository.Requests[2].Token.WaitHandle);
}
[Theory]
[InlineData("", "No nests on the server.")]
[InlineData("no such text", "No nests match the filter.")]
public async Task Summary_DistinguishesAnEmptyServerFromNoMatches(string search, string expected)
{
using var session = new NestBrowseSession(new PagedRepository(0));
await session.SetSearchAsync(search);
Assert.Equal(expected, session.Summary);
}
[Fact]
public async Task Dispose_CancelsTheRequestAndDiscardsItsLateResponse()
{
var repository = new ControlledRepository();
var session = new NestBrowseSession(repository);
var pending = session.SetSearchAsync("a");
session.Dispose();
repository.Requests[0].Reply.SetResult(Page("late"));
Assert.True(repository.Requests[0].Token.IsCancellationRequested);
Assert.False(await pending);
Assert.Null(session.Page);
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.RefreshAsync());
}
[Theory]
[InlineData(0)]
[InlineData(NestQuery.MaxLimit + 1)]
public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new NestBrowseSession(new PagedRepository(0), pageSize));
}
private static (NestSortField, bool, int) Last(PagedRepository repository)
{
var query = repository.Queries[^1];
return (query.Sort, query.Descending, query.Offset);
}
private static NestPage Page(string name) =>
new() { Items = new[] { new NestRecord { Id = Guid.NewGuid(), Name = name } }, Total = 1, Limit = 100 };
/// <summary>Serves pages of an in-memory set and records each query.</summary>
private sealed class PagedRepository : RepositoryBase
{
private readonly List<NestRecord> _records;
public PagedRepository(int count) =>
_records = Enumerable.Range(0, count).Select(i => new NestRecord { Id = Guid.NewGuid(), Name = $"Nest {i}" }).ToList();
public List<NestQuery> Queries { get; } = new();
public void RemoveLast() => _records.RemoveAt(_records.Count - 1);
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
Queries.Add(query);
var matches = query.NormalizedSearch.Length == 0 ? _records : new List<NestRecord>();
return Task.FromResult(new NestPage
{
Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(),
Total = matches.Count,
Offset = query.Offset,
Limit = query.Limit,
});
}
}
/// <summary>
/// Leaves every query pending until the test completes it. With
/// <see cref="CompleteOnCancellation"/>, cancellation completes the query synchronously
/// inside the canceller's call, as some HTTP handlers do.
/// </summary>
private sealed class ControlledRepository : RepositoryBase
{
public bool CompleteOnCancellation { get; init; }
public List<(NestQuery Query, TaskCompletionSource<NestPage> Reply, CancellationToken Token)> Requests { get; } = new();
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
var reply = new TaskCompletionSource<NestPage>();
if (CompleteOnCancellation)
cancellationToken.Register(() => reply.TrySetCanceled(cancellationToken));
Requests.Add((query, reply, cancellationToken));
return reply.Task;
}
}
private abstract class RepositoryBase : INestRepository
{
public abstract Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
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();
}
}
@@ -77,6 +77,7 @@ public class NestSaveSessionTests
} }
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
+73
View File
@@ -220,4 +220,77 @@ public class RemoteNestRepositoryTests
Assert.Contains("500", ex.Message); Assert.Contains("500", ex.Message);
Assert.Contains("disk on fire", ex.Message); Assert.Contains("disk on fire", ex.Message);
} }
[Fact]
public async Task QueryAsync_SendsEscapedBoundedQueryAndReadsPage()
{
var record = SampleRecord();
var handler = new StubHandler(_ => StubHandler.Json(new { items = new[] { record }, total = 41, offset = 20, limit = 20 }));
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090/base/");
var page = await repo.QueryAsync(new NestQuery { Search = " a&b=c %_\\ ", Offset = 20, Limit = 20 });
Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
Assert.Equal(
"http://server:8090/base/api/nests/query?search=a%26b%3Dc%20%25_%5C&sort=savedAt&order=desc&offset=20&limit=20",
handler.Requests[0].RequestUri!.AbsoluteUri);
Assert.Equal(record.Id, Assert.Single(page.Items).Id);
Assert.Equal(41, page.Total);
Assert.Equal(20, page.Offset);
Assert.Equal(20, page.Limit);
}
public static TheoryData<NestQuery> OutOfBoundsQueries() => new()
{
new NestQuery { Offset = -1 },
new NestQuery { Limit = 0 },
new NestQuery { Limit = NestQuery.MaxLimit + 1 },
new NestQuery { Search = new string('x', NestQuery.MaxSearchLength + 1) },
new NestQuery { Sort = (NestSortField)99 },
new NestQuery { Search = "Alpha\0not-present" },
};
[Fact]
public async Task QueryAsync_SendsSortColumnAndDirection()
{
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
await repo.QueryAsync(new NestQuery { Sort = NestSortField.PlateCount, Descending = false });
Assert.EndsWith(
"/api/nests/query?search=&sort=plateCount&order=asc&offset=0&limit=100",
handler.Requests[0].RequestUri!.AbsoluteUri);
}
[Theory]
[MemberData(nameof(OutOfBoundsQueries))]
public async Task QueryAsync_OutOfBounds_ThrowsWithoutSending(NestQuery query)
{
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
await Assert.ThrowsAsync<ArgumentException>(() => repo.QueryAsync(query));
Assert.Empty(handler.Requests);
}
[Fact]
public async Task QueryAsync_OldServerWithoutRoute_ReportsServerUpdateNeeded()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
var ex = await Assert.ThrowsAsync<IOException>(() => repo.QueryAsync(new NestQuery()));
Assert.Contains("Update the nest server", ex.Message);
}
[Fact]
public async Task QueryAsync_MoreItemsThanRequested_Throws()
{
var items = new[] { SampleRecord(), SampleRecord(), SampleRecord() };
var handler = new StubHandler(_ => StubHandler.Json(new { items, total = 3, offset = 0, limit = 2 }));
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
await Assert.ThrowsAsync<IOException>(() => repo.QueryAsync(new NestQuery { Limit = 2 }));
}
} }
@@ -215,6 +215,145 @@ public class OverlapReportStateTests
Assert.Equal("No material overlaps detected", state.Message); Assert.Equal("No material overlaps detected", state.Message);
} }
[Fact]
public void MovingOnePartRetainsOnlyUnaffectedPairsThroughRecheck()
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
var report = Analyze(plate);
Assert.True(state.TryPublish(state.Begin(plate), plate, report));
var untouched = report.Pairs[0];
plate.Parts[3].Offset(1e-10, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Null(state.Report);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
Assert.Same(untouched, Assert.Single(state.DisplayPairs));
var display = state.DisplayPairs;
Assert.False(state.EnsureFresh(plate));
Assert.Same(display, state.DisplayPairs);
var request = state.Begin(plate, automatic: true);
Assert.Equal(OverlapCheckStatus.Checking, state.Status);
Assert.Null(state.Report);
Assert.Same(display, state.DisplayPairs);
Assert.True(state.TryPublish(request, plate, Analyze(plate)));
Assert.Equal(2, state.DisplayPairs.Count);
Assert.Same(Assert.IsType<PlateOverlapReport>(state.Report).Pairs, state.DisplayPairs);
}
[Theory]
[InlineData("move")]
[InlineData("rotate")]
[InlineData("placed-program")]
[InlineData("clean-program")]
[InlineData("cutoff")]
[InlineData("replace")]
[InlineData("remove")]
[InlineData("reorder")]
public void FurtherEditsDropAffectedPairsEvenBeforeTheNextCompletedCheck(string edit)
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
plate.Parts[3].Offset(0.5, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Single(state.DisplayPairs);
var request = state.Begin(plate, automatic: true);
var pending = Analyze(plate);
var part = plate.Parts[0];
switch (edit)
{
case "move": part.Offset(0, 1e-10); break;
case "rotate": part.Rotate(0.01); break;
case "placed-program": part.Update(); break;
case "clean-program": part.BaseDrawing.Program = (Program)part.BaseDrawing.Program.Clone(); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
case "replace": plate.Parts[0] = Rectangle(); break;
case "remove": plate.Parts.RemoveAt(0); break;
case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break;
}
Assert.False(state.EnsureFresh(plate));
Assert.Empty(state.DisplayPairs);
Assert.False(state.TryPublish(request, plate, pending));
Assert.Null(state.Report);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
Assert.DoesNotContain("No material overlaps", state.Message);
}
[Theory]
[InlineData("invalidate")]
[InlineData("reset")]
[InlineData("plate")]
[InlineData("cancel")]
[InlineData("fail")]
public void HardInvalidationAndRequestFailureClearRetainedPairs(string edit)
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
plate.Parts[3].Offset(1, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Single(state.DisplayPairs);
var request = state.Begin(plate);
switch (edit)
{
case "invalidate": state.Invalidate(); break;
case "reset": state.Reset(); break;
case "plate": Assert.False(state.EnsureFresh(new Plate())); break;
case "cancel": state.Cancel(); break;
case "fail": Assert.True(state.TryFail(request, plate)); break;
}
Assert.Empty(state.DisplayPairs);
Assert.Null(state.Report);
}
[Fact]
public void AdditionalUnrelatedMotionDoesNotReplaceTheRetainedDisplayList()
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
plate.Parts[3].Offset(1, 0);
Assert.False(state.EnsureFresh(plate));
var display = state.DisplayPairs;
Assert.Single(display);
plate.Parts[3].Offset(1, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Same(display, state.DisplayPairs);
state.Begin(plate);
Assert.Same(display, state.DisplayPairs);
}
[Fact]
public void AppendingPartInvalidatesTheFullReportWithoutReplacingUnchangedDisplayPairs()
{
var plate = PlateWithParts();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
var display = state.DisplayPairs;
plate.Parts.Add(Rectangle());
state.Invalidate(plate); // Same entry point as the controller's collection event.
Assert.Null(state.Report);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
Assert.Same(display, state.DisplayPairs);
Assert.Single(state.DisplayPairs);
Assert.False(state.EnsureFresh(plate));
Assert.Same(display, state.DisplayPairs);
}
private static Plate PlateWithTwoPairs()
{
var plate = PlateWithParts();
var a = Rectangle();
var b = Rectangle();
a.Offset(20, 0);
b.Offset(20, 0);
plate.Parts.Add(a);
plate.Parts.Add(b);
return plate;
}
private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray()); private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray());
private static Plate PlateWithParts() private static Plate PlateWithParts()
@@ -0,0 +1,104 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Controls;
using OpenNest.Engine;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests;
public class ActionSelectAreaCutOffTests
{
public static IEnumerable<object[]> Cases()
{
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var quadrant in new[] { 1, 3 })
foreach (var farSide in new[] { false, true })
foreach (var alternate in new[] { false, true })
yield return new object[] { axis, quadrant, farSide, alternate };
}
[Theory]
[MemberData(nameof(Cases))]
public void SelectedAreaAndFillStayOnSelectedSideOfCutOff(
CutOffAxis axis, int quadrant, bool farSide, bool alternate) => RunSta(() =>
{
var plate = new Plate(new OpenNest.Geometry.Size(20, 20))
{
Quadrant = quadrant,
PartSpacing = 0.5,
};
var bounds = plate.WorkArea();
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
// Definitions alone must constrain selection, before any cutoff parts exist.
plate.CutOffs.Add(new CutOff(position, axis));
using var view = new SelectionView { Plate = plate };
var action = new ActionSelectArea(view);
try
{
var offset = farSide ? 5 : -5;
view.SetPoint(axis == CutOffAxis.Vertical
? new Vector(position.X + offset, position.Y)
: new Vector(position.X, position.Y + offset));
if (alternate)
view.ToggleSelectionDirection();
// Invoke the same update used by mouse movement without pixel rounding.
typeof(ActionSelectArea).GetMethod("UpdateSelectedArea", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(action, null);
var area = action.SelectedArea;
Assert.NotEqual(Box.Empty, area);
var limit = (axis == CutOffAxis.Vertical ? position.X : position.Y)
+ (farSide ? plate.PartSpacing : -plate.PartSpacing);
Assert.Equal(limit, axis == CutOffAxis.Vertical
? (farSide ? area.Left : area.Right)
: (farSide ? area.Bottom : area.Top), 8);
Assert.Equal(axis == CutOffAxis.Vertical ? bounds.Width : bounds.Length,
axis == CutOffAxis.Vertical ? area.Width : area.Length, 8);
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(0, 3);
program.LineTo(3, 3);
program.LineTo(3, 0);
program.LineTo(0, 0);
// ActionFillArea passes SelectedArea directly to this service.
var parts = PlateFillService.FillItem("Default", plate,
new NestItem { Drawing = new Drawing("square", program) }, area, null, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.All(parts, part =>
{
var box = part.BoundingBox;
Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6);
Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6);
});
}
finally
{
action.DisconnectEvents();
}
});
private sealed class SelectionView : PlateView
{
public void SetPoint(Vector point) => CurrentPoint = point;
public void ToggleSelectionDirection() => OnKeyUp(new KeyEventArgs(Keys.Space));
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The selection/fill test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
@@ -97,6 +97,80 @@ public class PlateOverlapOverlayTests
Assert.Null(run.View.OverlapOverlay.CachedPath); Assert.Null(run.View.OverlapOverlay.CachedPath);
}); });
[Fact]
public void UnchangedPairStaysVisibleDuringMotionAndPendingRecheck() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Add(Rectangle(20));
run.View.Plate.Parts.Add(Rectangle(21));
run.Finish();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.View.ZoomToPoint(new Vector(), 20);
using var image = new Bitmap(240, 240);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
var untouchedPoint = run.View.PointWorldToGraph(new Vector(2, 2));
var movedPoint = run.View.PointWorldToGraph(new Vector(22, 2));
overlay.Draw(graphics);
Assert.True(overlay.CachedPath.IsVisible(untouchedPoint));
Assert.True(overlay.CachedPath.IsVisible(movedPoint));
run.View.Plate.Parts[3].Offset(1e-10, 0);
overlay.Draw(graphics);
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
Assert.True(overlay.CachedPath.IsVisible(untouchedPoint));
Assert.False(overlay.CachedPath.IsVisible(movedPoint));
var retainedPath = overlay.CachedPath;
run.View.Plate.Parts[3].Offset(1, 0);
overlay.Draw(graphics);
Assert.Same(retainedPath, overlay.CachedPath);
Assert.Equal(1, run.Calls); // Paint never analyzes.
var task = run.Start();
var pending = run.Next();
overlay.Draw(graphics); // Both areas and centroid markers must render without a full report.
Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus);
Assert.Same(retainedPath, overlay.CachedPath);
run.View.Plate.Parts[0].Offset(1e-10, 0);
overlay.Draw(graphics);
Assert.Null(overlay.CachedPath);
pending.Complete();
run.Pump(task);
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
overlay.Draw(graphics);
Assert.Null(overlay.CachedPath); // Late completion cannot restore the removed pair.
});
[Theory]
[InlineData("editor")]
[InlineData("cancel")]
[InlineData("failure")]
public void RetainedPathClearsOnHardInvalidationOrRequestFailure(string edit) => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Add(Rectangle(20));
run.Finish();
run.View.Plate.Parts[2].Offset(1, 0);
run.Paint();
var path = run.View.OverlapOverlay.CachedPath;
Assert.NotNull(path);
var task = run.Start();
var pending = run.Next();
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
switch (edit)
{
case "editor": run.View.InvalidateOverlapCheck(); pending.Complete(); break;
case "cancel": run.View.CancelOverlapCheck(); pending.Complete(); break;
case "failure": pending.Fail(); break;
}
run.Pump(task);
Assert.Null(run.View.OverlapReport);
run.Paint();
Assert.Null(run.View.OverlapOverlay.CachedPath);
});
[Fact] [Fact]
public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() => public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
{ {
@@ -233,11 +307,10 @@ public class PlateOverlapOverlayTests
}); });
[Theory] [Theory]
[InlineData("add")]
[InlineData("remove")] [InlineData("remove")]
[InlineData("replace")] [InlineData("replace")]
[InlineData("clear")] [InlineData("clear")]
public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() => public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
{ {
using var run = new OverlayRun(); using var run = new OverlayRun();
run.Finish(); run.Finish();
@@ -247,7 +320,6 @@ public class PlateOverlapOverlayTests
Assert.NotNull(run.View.OverlapOverlay.CachedPath); Assert.NotNull(run.View.OverlapOverlay.CachedPath);
switch (edit) switch (edit)
{ {
case "add": run.View.Plate.Parts.Add(Rectangle()); break;
case "remove": run.View.Plate.Parts.RemoveAt(0); break; case "remove": run.View.Plate.Parts.RemoveAt(0); break;
case "replace": run.View.Plate.Parts[0] = Rectangle(); break; case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
case "clear": run.View.Plate.Parts.Clear(); break; case "clear": run.View.Plate.Parts.Clear(); break;
@@ -257,6 +329,23 @@ public class PlateOverlapOverlayTests
Assert.Null(run.View.OverlapOverlay.CachedPath); Assert.Null(run.View.OverlapOverlay.CachedPath);
}); });
[Fact]
public void AppendingPartRetainsTheExistingPairsPathButInvalidatesTheFullReport() => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
run.Paint();
var path = run.View.OverlapOverlay.CachedPath;
Assert.NotNull(path);
run.View.Plate.Parts.Add(Rectangle());
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
run.Paint();
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
Assert.Equal(1, run.Calls);
});
[Fact] [Fact]
public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() => public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
{ {
+14
View File
@@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenN
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -312,6 +314,18 @@ Global
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU {A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU {A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU {A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.ActiveCfg = Debug|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.Build.0 = Debug|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.ActiveCfg = Debug|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.Build.0 = Debug|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.Build.0 = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.ActiveCfg = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.Build.0 = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.ActiveCfg = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+26 -12
View File
@@ -33,7 +33,7 @@ internal sealed class OverlapOverlayController : IDisposable
private ObservableList<Part> observedParts; private ObservableList<Part> observedParts;
private CancellationTokenSource cancellation; private CancellationTokenSource cancellation;
private GraphicsPath path; private GraphicsPath path;
private PlateOverlapReport pathReport; private IReadOnlyList<PlateOverlapPair> pathPairs;
private float pathScale; private float pathScale;
private Units capturedUnits; private Units capturedUnits;
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>(); private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
@@ -139,7 +139,6 @@ internal sealed class OverlapOverlayController : IDisposable
var source = new CancellationTokenSource(); var source = new CancellationTokenSource();
cancellation = source; cancellation = source;
var token = source.Token; var token = source.Token;
ReleasePath();
NotifyChanged(toStatusBar: !automatic); NotifyChanged(toStatusBar: !automatic);
try try
{ {
@@ -204,13 +203,22 @@ internal sealed class OverlapOverlayController : IDisposable
{ {
materialCache.Clear(); materialCache.Clear();
baseline = null; baseline = null;
Invalidate(); state.Invalidate();
CancelWorker();
ReleasePath();
NotifyChanged();
} }
public void Invalidate() public void Invalidate()
{ {
var generation = state.Generation; var generation = state.Generation;
state.Invalidate(); var display = state.DisplayPairs;
state.Invalidate(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation == state.Generation) if (generation == state.Generation)
{ {
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one // Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
@@ -223,7 +231,6 @@ internal sealed class OverlapOverlayController : IDisposable
return; return;
} }
CancelWorker(); CancelWorker();
ReleasePath();
NotifyChanged(); NotifyChanged();
} }
@@ -250,6 +257,8 @@ internal sealed class OverlapOverlayController : IDisposable
private void NotifyChanged(bool toStatusBar = false) private void NotifyChanged(bool toStatusBar = false)
{ {
if (!ReferenceEquals(pathPairs, state.DisplayPairs))
ReleasePath();
ClearHover(); ClearHover();
if (disposed || view.IsDisposed || view.Disposing) if (disposed || view.IsDisposed || view.Disposing)
return; return;
@@ -343,7 +352,7 @@ internal sealed class OverlapOverlayController : IDisposable
UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below. UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
ValidateHoverTransform(); ValidateHoverTransform();
if (state.Report != null) if (state.DisplayPairs.Count > 0)
{ {
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both) if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
{ {
@@ -366,11 +375,16 @@ internal sealed class OverlapOverlayController : IDisposable
if (disposed) if (disposed)
return false; return false;
var generation = state.Generation; var generation = state.Generation;
var display = state.DisplayPairs;
var fresh = state.EnsureFresh(view.Plate); var fresh = state.EnsureFresh(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation != state.Generation) if (generation != state.Generation)
{ {
CancelWorker(); CancelWorker();
ReleasePath();
NotifyChanged(); NotifyChanged();
} }
return fresh; return fresh;
@@ -457,7 +471,7 @@ internal sealed class OverlapOverlayController : IDisposable
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale); using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
// Stack coincident pair labels instead of replacing them with a fragment count. // Stack coincident pair labels instead of replacing them with a fragment count.
var labelRows = new Dictionary<PointF, int>(); var labelRows = new Dictionary<PointF, int>();
foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId)) foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
{ {
var center = view.PointWorldToGraph(pair.Centroid); var center = view.PointWorldToGraph(pair.Centroid);
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y)) if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
@@ -532,13 +546,13 @@ internal sealed class OverlapOverlayController : IDisposable
private void EnsurePath() private void EnsurePath()
{ {
if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null) if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null)
return; return;
ReleasePath(); ReleasePath();
var next = new GraphicsPath(FillMode.Winding); var next = new GraphicsPath(FillMode.Winding);
try try
{ {
foreach (var pair in state.Report.Pairs) foreach (var pair in state.DisplayPairs)
{ {
foreach (var region in pair.Regions) foreach (var region in pair.Regions)
{ {
@@ -560,7 +574,7 @@ internal sealed class OverlapOverlayController : IDisposable
} }
} }
path = next; path = next;
pathReport = state.Report; pathPairs = state.DisplayPairs;
pathScale = view.ViewScale; pathScale = view.ViewScale;
} }
catch catch
@@ -592,7 +606,7 @@ internal sealed class OverlapOverlayController : IDisposable
{ {
path?.Dispose(); path?.Dispose();
path = null; path = null;
pathReport = null; pathPairs = null;
} }
public void Dispose() public void Dispose()
+134 -80
View File
@@ -1,7 +1,5 @@
using System; using System;
using System.Data;
using System.Drawing; using System.Drawing;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using OpenNest.Data; using OpenNest.Data;
@@ -9,18 +7,25 @@ using OpenNest.Data;
namespace OpenNest.Forms; namespace OpenNest.Forms;
/// <summary> /// <summary>
/// File &gt; Open (Database mode): lists nests from the shared nest server with /// File &gt; Open (Database mode): browses nests on the shared nest server one bounded
/// filterable/sortable metadata columns, and lets the operator open or delete one. /// page at a time. Filtering, sorting and paging run on the server through
/// <see cref="NestBrowseSession"/>; this form only renders its state.
/// </summary> /// </summary>
public sealed class SavedNestsForm : Form public sealed class SavedNestsForm : Form
{ {
private const string IdColumn = "Id";
private const string LoadingText = "Loading...";
private readonly INestRepository repository; private readonly INestRepository repository;
private readonly NestBrowseSession session;
private readonly DataGridView grid; private readonly DataGridView grid;
private readonly TextBox searchBox; private readonly TextBox searchBox;
private readonly Timer searchTimer;
private readonly Button openButton; private readonly Button openButton;
private readonly Button deleteButton; private readonly Button deleteButton;
private readonly Button refreshButton; private readonly Button previousButton;
private NestRecord[] records = Array.Empty<NestRecord>(); private readonly Button nextButton;
private readonly ToolStripStatusLabel statusLabel;
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary> /// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
public Guid SelectedId { get; private set; } public Guid SelectedId { get; private set; }
@@ -28,6 +33,7 @@ public sealed class SavedNestsForm : Form
public SavedNestsForm(INestRepository repository) public SavedNestsForm(INestRepository repository)
{ {
this.repository = repository; this.repository = repository;
session = new NestBrowseSession(repository);
Text = "Open Nest — Database"; Text = "Open Nest — Database";
StartPosition = FormStartPosition.CenterParent; StartPosition = FormStartPosition.CenterParent;
@@ -36,15 +42,37 @@ public sealed class SavedNestsForm : Form
MaximizeBox = false; MaximizeBox = false;
ShowInTaskbar = false; ShowInTaskbar = false;
var topPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(6) }; var topPanel = new FlowLayoutPanel
var searchLabel = new Label { Text = "Filter:", AutoSize = true, Location = new Point(6, 10) }; {
searchBox = new TextBox { Location = new Point(50, 6), Width = 300 }; Dock = DockStyle.Top,
searchBox.TextChanged += (_, _) => ApplyFilter(); Height = 34,
refreshButton = new Button { Text = "Refresh", AutoSize = true, Location = new Point(360, 5) }; Padding = new Padding(3),
refreshButton.Click += async (_, _) => await LoadAsync(); WrapContents = false,
};
var searchLabel = new Label { Text = "Filter:", AutoSize = true, Margin = new Padding(3, 7, 0, 0) };
searchBox = new TextBox { Width = 300, MaxLength = NestQuery.MaxSearchLength };
searchTimer = new Timer { Interval = 300 };
searchTimer.Tick += async (_, _) =>
{
searchTimer.Stop();
await RunAsync(() => session.SetSearchAsync(searchBox.Text));
};
searchBox.TextChanged += (_, _) =>
{
searchTimer.Stop();
searchTimer.Start();
};
var refreshButton = new Button { Text = "Refresh", AutoSize = true };
refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync);
previousButton = new Button { Text = "< Previous", AutoSize = true, Enabled = false };
previousButton.Click += async (_, _) => await RunAsync(session.PreviousPageAsync);
nextButton = new Button { Text = "Next >", AutoSize = true, Enabled = false };
nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync);
topPanel.Controls.Add(searchLabel); topPanel.Controls.Add(searchLabel);
topPanel.Controls.Add(searchBox); topPanel.Controls.Add(searchBox);
topPanel.Controls.Add(refreshButton); topPanel.Controls.Add(refreshButton);
topPanel.Controls.Add(previousButton);
topPanel.Controls.Add(nextButton);
grid = new DataGridView grid = new DataGridView
{ {
@@ -56,6 +84,32 @@ public sealed class SavedNestsForm : Form
MultiSelect = false, MultiSelect = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
}; };
AddColumn("Name", NestSortField.Name, typeof(string));
AddColumn("Customer", NestSortField.Customer, typeof(string));
AddColumn("Status", NestSortField.Status, typeof(string));
AddColumn("Material", NestSortField.Material, typeof(string));
AddColumn("Date Created", NestSortField.DateCreated, typeof(DateTime));
AddColumn("Date Modified", NestSortField.DateModified, typeof(DateTime));
AddColumn("Thickness", NestSortField.Thickness, typeof(double));
AddColumn("Plates", NestSortField.PlateCount, typeof(int));
AddColumn("Parts", NestSortField.PartCount, typeof(int));
AddColumn("Made By", NestSortField.MadeBy, typeof(string));
AddColumn("Comments", NestSortField.Comments, typeof(string));
AddColumn("File Size", NestSortField.FileSize, typeof(long));
AddColumn("Saved", NestSortField.SavedAt, typeof(DateTime));
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = IdColumn,
ValueType = typeof(Guid),
Visible = false,
SortMode = DataGridViewColumnSortMode.NotSortable,
});
// Sorting a page locally would misrepresent the archive; the server sorts all matches.
grid.ColumnHeaderMouseClick += async (_, e) =>
{
if (e.Button == MouseButtons.Left && grid.Columns[e.ColumnIndex].Tag is NestSortField field)
await RunAsync(() => session.SortByAsync(field));
};
grid.CellDoubleClick += (_, e) => grid.CellDoubleClick += (_, e) =>
{ {
if (e.RowIndex >= 0) if (e.RowIndex >= 0)
@@ -78,6 +132,10 @@ public sealed class SavedNestsForm : Form
buttons.Controls.Add(openButton); buttons.Controls.Add(openButton);
buttons.Controls.Add(deleteButton); buttons.Controls.Add(deleteButton);
var statusStrip = new StatusStrip { SizingGrip = false };
statusLabel = new ToolStripStatusLabel { Spring = true, TextAlign = ContentAlignment.MiddleLeft };
statusStrip.Items.Add(statusLabel);
grid.SelectionChanged += (_, _) => grid.SelectionChanged += (_, _) =>
{ {
var hasSelection = grid.SelectedRows.Count > 0; var hasSelection = grid.SelectedRows.Count > 0;
@@ -88,91 +146,71 @@ public sealed class SavedNestsForm : Form
Controls.Add(grid); Controls.Add(grid);
Controls.Add(topPanel); Controls.Add(topPanel);
Controls.Add(buttons); Controls.Add(buttons);
Controls.Add(statusStrip);
CancelButton = cancel; CancelButton = cancel;
Shown += async (_, _) => await LoadAsync(); Shown += async (_, _) => await RunAsync(session.RefreshAsync);
} }
private async Task LoadAsync() private void AddColumn(string name, NestSortField sortField, Type valueType)
{ {
Cursor = Cursors.WaitCursor; grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = name,
HeaderText = name,
ValueType = valueType,
SortMode = DataGridViewColumnSortMode.Programmatic,
Tag = sortField,
});
}
/// <summary>
/// Sends one browse request and renders its page. A request superseded by a newer one
/// renders nothing; a failure clears the rows and shows the error in the status line.
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
/// </summary>
private async Task RunAsync(Func<Task<bool>> request)
{
statusLabel.Text = LoadingText;
try try
{ {
var list = await repository.ListAsync(); var started = request();
if (IsDisposed) UpdateNavigation();
return; if (await started && !IsDisposed)
records = list.ToArray(); Populate(session.Page);
ApplyFilter();
} }
catch (Exception ex) catch (Exception ex)
{ {
if (!IsDisposed) if (!IsDisposed)
MessageBox.Show( {
this, grid.Rows.Clear();
$"Could not load nests from the server: {ex.Message}", statusLabel.Text = $"Could not load nests from the server: {ex.Message}";
"Open Nest — Database", }
MessageBoxButtons.OK,
MessageBoxIcon.Error
);
} }
finally finally
{ {
if (!IsDisposed) if (!IsDisposed)
Cursor = Cursors.Default; {
UpdateNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!session.IsLoading && statusLabel.Text == LoadingText)
statusLabel.Text = session.Summary;
}
} }
} }
private void ApplyFilter() private void UpdateNavigation()
{ {
var filter = searchBox.Text.Trim(); previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
var filtered = string.IsNullOrEmpty(filter) nextButton.Enabled = !session.IsLoading && session.CanGoNext;
? records
: records
.Where(r =>
Contains(r.Name, filter)
|| Contains(r.Customer, filter)
|| Contains(r.Material, filter)
|| Contains(r.MadeBy, filter)
|| Contains(r.Comments, filter)
|| Contains(r.Status.ToString(), filter)
|| Contains(FormatStatus(r.Status), filter)
|| Contains(r.DateCreated.ToString("g"), filter)
|| Contains(r.DateModified.ToString("g"), filter)
|| Contains(r.SavedAt.ToString("g"), filter)
|| Contains(r.Thickness.ToString(), filter)
|| Contains(r.PlateCount.ToString(), filter)
|| Contains(r.PartCount.ToString(), filter)
|| Contains(r.FileSize.ToString(), filter)
)
.ToArray();
Populate(filtered);
} }
private static bool Contains(string value, string filter) => private void Populate(NestPage page)
!string.IsNullOrEmpty(value) && value.Contains(filter, StringComparison.OrdinalIgnoreCase);
private void Populate(NestRecord[] rows)
{ {
var table = new DataTable(); grid.Rows.Clear();
table.Columns.Add("Name", typeof(string)); foreach (var record in page.Items)
table.Columns.Add("Customer", typeof(string));
table.Columns.Add("Status", typeof(string));
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Date Created", typeof(DateTime));
table.Columns.Add("Date Modified", typeof(DateTime));
table.Columns.Add("Thickness", typeof(double));
table.Columns.Add("Plates", typeof(int));
table.Columns.Add("Parts", typeof(int));
table.Columns.Add("Made By", typeof(string));
table.Columns.Add("Comments", typeof(string));
table.Columns.Add("File Size", typeof(long));
table.Columns.Add("Saved", typeof(DateTime));
table.Columns.Add("Id", typeof(Guid));
foreach (var record in rows.OrderByDescending(r => r.SavedAt))
{ {
table.Rows.Add( grid.Rows.Add(
record.Name, record.Name,
record.Customer, record.Customer,
FormatStatus(record.Status), FormatStatus(record.Status),
@@ -190,9 +228,14 @@ public sealed class SavedNestsForm : Form
); );
} }
grid.DataSource = table; foreach (DataGridViewColumn column in grid.Columns)
if (grid.Columns.Contains("Id")) {
grid.Columns["Id"].Visible = false; column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
statusLabel.Text = session.Summary;
} }
private static string FormatStatus(NestStatus status) => status switch private static string FormatStatus(NestStatus status) => status switch
@@ -207,7 +250,7 @@ public sealed class SavedNestsForm : Form
{ {
if (grid.SelectedRows.Count == 0) if (grid.SelectedRows.Count == 0)
return; return;
SelectedId = (Guid)grid.SelectedRows[0].Cells["Id"].Value; SelectedId = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();
} }
@@ -217,7 +260,7 @@ public sealed class SavedNestsForm : Form
if (grid.SelectedRows.Count == 0) if (grid.SelectedRows.Count == 0)
return; return;
var id = (Guid)grid.SelectedRows[0].Cells["Id"].Value; var id = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? ""; var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? "";
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
@@ -235,7 +278,7 @@ public sealed class SavedNestsForm : Form
{ {
await repository.DeleteAsync(id); await repository.DeleteAsync(id);
if (!IsDisposed) if (!IsDisposed)
await LoadAsync(); await RunAsync(session.RefreshAsync);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -254,4 +297,15 @@ public sealed class SavedNestsForm : Form
deleteButton.Enabled = grid.SelectedRows.Count > 0; deleteButton.Enabled = grid.SelectedRows.Count > 0;
} }
} }
protected override void Dispose(bool disposing)
{
if (disposing)
{
searchTimer.Dispose();
session.Dispose();
}
base.Dispose(disposing);
}
} }
+23
View File
@@ -0,0 +1,23 @@
# OpenNest.Server deployment example: one instance, one local data volume, LAN-only.
# Usage: docker compose --env-file <local-env> -f compose.server.yaml up -d
# Start from OpenNest.Server/server.env.example. See docs/nest-storage.md before deploying.
name: opennest-server
services:
opennest-server:
image: ${OPENNEST_SERVER_IMAGE:?Set a tested version or digest}
restart: unless-stopped
ports:
- "${OPENNEST_BIND_ADDRESS:?Set 127.0.0.1 for testing or a trusted LAN address}:${OPENNEST_HOST_PORT:-8090}:8090"
environment:
ASPNETCORE_URLS: http://+:8090
OPENNEST_DB: /app/data/nests.db
volumes:
- opennest-data:/app/data
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
opennest-data:
# A restore switches to a separately verified volume instead of overwriting this one.
name: ${OPENNEST_DATA_VOLUME:-opennest-data}
+84
View File
@@ -0,0 +1,84 @@
# Fixed-program cutting-route foundation
`OpenNest.Engine.CuttingPlanning.CuttingPlanService` currently plans a direct-XY
route through fixed, contiguous whole-part programs. This is the first foundation
of the unified cutting planner, not a replacement desktop command or an Apply API.
Existing desktop sequencing, lead assignment and per-attempt posting review are
unchanged.
## Calling the service
Create a `CuttingPlanRequest` from source placements and an explicit modeled start
point, then call `Capture` while their order, programs and poses are stable. Pass
the resulting `CuttingPlanSnapshot` to `Plan` on a worker. `Plan(request)` combines
both steps for synchronous callers. The default start is `Vector.Zero`; this is
not a discovered controller position. The diagnostic overload
`PostVerificationAnalyzer.Analyze(nest, startPoint)` uses the same modeled start.
Capture reads absolute/incremental instructions and shared subprogram calls into
owned immutable motion values. Placed rotations are already baked into programs;
placement translation is applied once. It does not clone recursive graphs, rebind
subcalls through rotating setters, or attach live drawings to preview plates.
Source `Part` references are identity handles only; worker planning never reads
their mutable state. A captured snapshot is deliberately historical, not a
freshness check against later edits.
Both locked and unlocked programs remain fixed. Search changes only the proposed
whole-part order and never edits source order, programs, settings, locks, poses or
quantity accounting. Branches are ranked by modeled rapid distance and source
ordinal, with bounded deterministic backtracking. The expansion budget defaults
to 20,000 attempted placements. Actual departure motions, including lead-outs and
subprograms, determine the next approach.
## Results and refusal
`Ready` means only that every captured placement occurs once and the full
fixed-program route was replayed with a fresh completed-contour checker without
rapid, missing-lead or incomplete-motion findings. `IndependentlyReplayed` records
that replay; it does not certify final NC or machine safety. Result ordinals and
finding identities refer to the original zero-based source list, not the proposed
sequence positions.
The checker uses the same native line/arc contact, contour completion and actual
gap semantics as pre-post diagnostics. Future contours are not yet obstacles;
completed holes in the same part are obstacles immediately. Stale tab settings
are not evidence of retention.
- `ConstraintConflict`: a fixed internal program or all explored whole-part orders
violate the modeled route constraints. Reordering cannot repair a fixed rapid
crossing its own completed hole.
- `UnsupportedGeometry`: unsupported motion semantics or incomplete retention
checks. Cutoff dependencies and scribe-only drawings are outside this slice.
- `InvalidInput`: malformed/missing/duplicate placements, empty input, invalid
geometry or a nonpositive budget.
- `NoSolutionWithinBudget`: search reached its bound, not proof that no route exists.
- `Cancelled`: capture or worker cancellation, with no live mutation.
Every non-ready result has no proposed order and no unsafe fallback.
## Limits and next hardening
This service does not check clean-material overlap, enclosing-hole/insert
release dependencies, contour coverage against clean geometry, lead paths through
other material, cutoff order, or physical retention strength. It does not generate
entries/leads, change internal contour order, install programs atomically, check
staleness at Apply, write CNC, or set posting consent. A `Ready` fixed-program route
can still be unsuitable for cutting. Do not apply it as a complete cutting plan.
Next slices must add explicit contour emission and lead validation, containment
and cutoff dependencies, exact freshness/atomic application, then desktop caller
migration and legacy retirement. Native Windows interaction, supplied-job routing
coverage and actual posted order remain separate acceptance gates. Fresh
[pre-post verification](post-verification.md) is still required at posting
boundaries, and it is not a physical safety qualification.
## Portable regression gate
```sh
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~CuttingPlanning|FullyQualifiedName~PostVerificationAnalyzerTests'
```
The synthetic three-part fixture starts with A,B,C crossing completed A; B,A,C
replays without findings. Controls cover a locked internal-hole crossing,
backtracking, native shared-hole motions, bounded refusal, cancellation, immutable
ownership, source-reference identity and complete-proposal replay rejection.
+24 -7
View File
@@ -34,8 +34,10 @@ fragment counts. This diagnostic checks shared material, not minimum spacing,
plate edges, or cutting-path crossings. plate edges, or cutting-path crossings.
In a nest window the active plate is checked automatically. Any layout edit In a nest window the active plate is checked automatically. Any layout edit
(add, remove, reorder, move, rotate, fill) clears the overlay and shows (add, remove, reorder, move, rotate, fill) invalidates the full report and shows
**Overlaps: check pending…**; once the layout has been unchanged for 0.5 s and no **Overlaps: check pending…**, but highlights for unchanged pairs remain visible.
Only highlights involving changed ordered input slots are removed; once the
layout has been unchanged for 0.5 s and no
mouse button, modal dialog, or fill progress window is active, the check reruns. mouse button, modal dialog, or fill progress window is active, the check reruns.
Automatic results appear only in the canvas label, so the status bar keeps the Automatic results appear only in the canvas label, so the status bar keeps the
last command's message, and an automatic check keeps Display > Off rather than last command's message, and an automatic check keeps Display > Off rather than
@@ -211,8 +213,21 @@ changed code count or rotation is detected). Preparing material dominates
first-check time for drawings with many holes. Incremental analysis reuses a pair first-check time for drawings with many holes. Incremental analysis reuses a pair
only when both parts have the same cached source, bit-identical pose, and the same only when both parts have the same cached source, bit-identical pose, and the same
relative input order (clipping is operand-order sensitive), then renumbers it. relative input order (clipping is operand-order sensitive), then renumbers it.
`InvalidateOverlapCheck()` clears the cache and baseline, so in-place program `InvalidateOverlapCheck()` clears the cache, baseline and every highlight, so
editors must keep calling it before loading. in-place program editors must keep calling it before loading.
Moving one part hides only highlights involving changed parts. Unchanged pairs
remain visible throughout the quiet period and background recheck, sharing their
existing immutable regions rather than recalculating them. `OverlapReportState`
keeps these `DisplayPairs` separate from its full `Report`: the full report is
unavailable and the label stays pending/out-of-date/checking until a fresh result
is published. Retained highlights are known overlaps, never an all-clear for the
edited layout. Each paint checks exact poses and references again, including
further edits while an earlier check is pending. Hover details remain disabled
until the full report is current. Collection edits conservatively discard pairs
whose ordered input slots changed; this display-only path does not renumber them.
Explicit geometry invalidation, plate switch, handle loss, cancellation and
failure clear all retained highlights.
Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check
takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental
@@ -227,7 +242,7 @@ PlateView draws the controller overlay after work-area/debug-remnant drawing and
before action paint subscribers and hover tooltips. One consistently wound path before action paint subscribers and hover tooltips. One consistently wound path
is filled once, avoiding fragment outlines, internal triangulation seams, and is filled once, avoiding fragment outlines, internal triangulation seams, and
darker triple coverage. World-to-graph conversion excludes pan, because PlateView darker triple coverage. World-to-graph conversion excludes pan, because PlateView
already applies origin translation. Paths are rebuilt for report/scale changes, already applies origin translation. Paths are rebuilt for displayed-pair/scale changes,
not ordinary repaints or panning. The state label saves/restores graphics state. not ordinary repaints or panning. The state label saves/restores graphics state.
Centroid hit tests use only cached report coordinates and DPI-scaled screen Centroid hit tests use only cached report coordinates and DPI-scaled screen
radii. Hover clears on edits, mode/request/view changes, leave, and teardown. radii. Hover clears on edits, mode/request/view changes, leave, and teardown.
@@ -258,7 +273,8 @@ coincident duplicates, and covers pair reuse, issue renumbering, cache clearing
cancellation. `OverlapAutoCheckSchedulerTests` covers the quiet period, interaction cancellation. `OverlapAutoCheckSchedulerTests` covers the quiet period, interaction
waits, and the no-retry rule for canceled or failed layouts. waits, and the no-retry rule for canceled or failed layouts.
`OverlapReportStateTests` verifies request supersession, exact pose/reference `OverlapReportStateTests` verifies request supersession, exact pose/reference
freshness, stale clearing, cancellation, and incomplete-versus-clear messaging. freshness, per-pair display retention through repeated edits and pending checks,
hard invalidation, cancellation, and incomplete-versus-clear messaging.
`PolygonAreaMomentsTests` covers analytic `PolygonAreaMomentsTests` covers analytic
centers, unequal/disconnected fragments, winding, closure, large translations, centers, unequal/disconnected fragments, winding, closure, large translations,
and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit
@@ -277,7 +293,8 @@ dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not
execute Windows tests or verify appearance, DPI, or interaction. On Windows, execute Windows tests or verify appearance, DPI, or interaction. On Windows,
check partial overlap, containment, inside-hole placement, pan/zoom and quadrant check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
alignment, stale clearing during edits/plate switches, converter cancellation, alignment, changed-pair clearing and unchanged-pair retention during edits and
pending rechecks, full clearing on plate switches and converter cancellation,
the pending label and automatic recheck after dragging a part onto another, the pending label and automatic recheck after dragging a part onto another,
crowded-marker PageUp/PageDown access to the last pair, and repeated crowded-marker PageUp/PageDown access to the last pair, and repeated
check/toggle/close cycles without GDI/disposed-control errors. check/toggle/close cycles without GDI/disposed-control errors.
+319 -14
View File
@@ -4,9 +4,13 @@ The desktop app can save nests two ways:
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP - **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). archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
- **Database mode**: Open lists server records with filterable metadata and - **Database mode**: Open browses server records 100 at a time. The filter box
sortable columns; Save creates a record on first save and updates that same searches name, customer, material, made by, comments and status on the server
record afterward. Save As creates a new record (a copy). A failed save keeps after a short typing pause; clicking a column header sorts every match on the
server (click again to reverse); Previous/Next move between pages and the
status line shows the range and total. Dates and numbers are not searched as
text; sort their column instead. Save creates a record on first save and
updates that same record afterward. Save As creates a new record (a copy). A failed save keeps
the previous record association; switching server URLs creates a record on the the previous record association; switching server URLs creates a record on the
new server rather than updating an id from the previous one. Delete in the new server rather than updating an id from the previous one. Delete in the
saved-nest list permanently removes the selected server record after confirmation. saved-nest list permanently removes the selected server record after confirmation.
@@ -45,8 +49,9 @@ with enums serialized as strings (`JsonSerializerDefaults.Web` +
| Method | Path | Body | Response | | Method | Path | Body | Response |
|---|---|---|---| |---|---|---|---|
| GET | `/healthz` | — | `{ "status": "ok" }` | | GET | `/healthz` | — | 200 `{ "status": "ok" }` after a live database query; 503 `{ "status": "unavailable" }` on storage failure (no internal details) |
| GET | `/api/nests` | — | `NestRecord[]`, newest `savedAt` first | | GET | `/api/nests` | — | `NestRecord[]`, newest `savedAt` first. Full, unbounded enumeration for backup manifests, restore checks and the container smoke; browsing uses `/api/nests/query` |
| GET | `/api/nests/query?search=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter |
| GET | `/api/nests/{id}` | — | `NestRecord` or 404 | | GET | `/api/nests/{id}` | — | `NestRecord` or 404 |
| GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 | | GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 |
| POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid | | POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid |
@@ -57,12 +62,70 @@ with enums serialized as strings (`JsonSerializerDefaults.Web` +
There is no authentication; this is a LAN-only service. Do not expose it There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one. outside the shop network without adding one.
### Browsing query
`GET /api/nests/query` filters, orders and pages in SQLite, so a client receives
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`).
`%`, `_` 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
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
deterministically. `total` counts every match and is read with the page in one
database hold. A save between two page requests can move a row across a page
boundary.
- Unknown, repeated, non-integer or out-of-range parameters return 400 instead of
being ignored or clamped. `RemoteNestRepository.QueryAsync` checks the same
bounds before sending, and reports an HTTP 404 from an older server as a
server version that must be updated.
## Storage ## Storage
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with
`journal_mode=WAL`. Each row holds the metadata columns plus the `.nest` `journal_mode=WAL`. Each row holds the metadata columns plus the `.nest`
archive as a `BLOB`. The database file path comes from `--database=<path>`, archive as a `BLOB`. The database file path comes from `--database=<path>`,
then `OPENNEST_DB`, defaulting to `./data/nests.db`. then `OPENNEST_DB`, defaulting to `./data/nests.db`. The server opens the
database at startup, so an unusable path fails startup rather than the first request.
Run **one service instance with its database on a local filesystem**. Multiple PCs
may use that instance, but do not share the SQLite file between replicas or place
it on SMB/NFS storage. A private reentrant lock serializes complete database
operations (including readers, write readbacks, health checks, and disposal);
HTTP upload/body reading happens outside that lock. `/healthz` executes `SELECT 1`
on the live connection; it is a connection check, not a backup, integrity scan,
or guarantee of future disk capacity.
Concurrent edits of the same record remain **last-writer-wins**: there is no
optimistic version check or document lock. Coordinate editing with other operators
to avoid overwriting their changes.
## Server integration tests
```sh
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
```
Cross-platform; runs in both CI workflows. Each test hosts the production routes in
memory (`WebApplicationFactory`) against its own temporary SQLite file and drives
them through the real `RemoteNestRepository` and `NestSaveSession`: create, list,
download, same-record update, copy, metadata-only update (archive and `fileSize`
unchanged), and delete. Missing/invalid upload parts must return 400 and unknown ids
404, both leaving every stored record and archive hash unchanged. Tests never open
`data/nests.db` or `OPENNEST_DB`; the temporary directory is removed when the host is
disposed. Concurrent HTTP clients and database-level readers/writers must retain
exact record membership, metadata, and archive bytes; a monitor-ownership test
checks every operation without relying on stress timing. Health tests check the
unchanged success response and a detail-free 503 after the database is disposed.
The [container smoke](#isolated-container-smoke) remains the image-level check.
## Running ## Running
@@ -74,16 +137,258 @@ dotnet run --project OpenNest.Server/OpenNest.Server.csproj
Listens on `ASPNETCORE_URLS` (default Kestrel ports) unless overridden. Listens on `ASPNETCORE_URLS` (default Kestrel ports) unless overridden.
Docker (built from the repository root so it can see `OpenNest.Data`): ### Docker image
Docker must be built from the repository root: the server references
`OpenNest.Data`, which references `OpenNest.Core`. The Dockerfile copies all three
project files before restore and their source/resources before publish, including
Data's embedded `Defaults/CL-980.json`. The allowlist `.dockerignore` excludes
other projects, host `bin`/`obj`, local databases, environment files, and launch
settings. The server image uses the .NET 8 SDK to build and the .NET 8 ASP.NET
runtime to run; IO/Engine are used only by the host-side smoke tool, not the image.
```sh ```sh
docker build -f OpenNest.Server/Dockerfile -t opennest-server . docker build --pull -f OpenNest.Server/Dockerfile -t opennest-server:local .
docker run -d -p 8090:8090 -v opennest-data:/app/data opennest-server # A release build stamps its version and exact source commit:
docker build --pull -f OpenNest.Server/Dockerfile --build-arg VERSION=X.Y.Z \
--build-arg SOURCE_REVISION="$(git rev-parse HEAD)" -t opennest-server:X.Y.Z .
``` ```
The image listens on `:8090` and stores `nests.db` under `/app/data`, which Without build arguments the image is labeled version `0.0.0-dev`, revision
should be a named volume or bind mount so nests survive container recreation. `unknown`: a development build, never a release. The OCI labels are
`org.opencontainers.image.source`, `version`, `revision` and `base.name`.
`SDK_IMAGE`/`RUNTIME_IMAGE` build arguments accept digest-pinned base images;
for a release, record the base digests the build log resolved.
Point the desktop app's **File > Storage Mode...** server URL at Runtime contract:
`http://<host>:8090` (no trailing slash required; `RemoteNestRepository`
trims it). - Container port 8090 (`ASPNETCORE_URLS=http://+:8090`). Change the host port
mapping, not the container port: the healthcheck probes 8090.
- Runs as the .NET base image's non-root `app` user (UID/GID 1654). Application
files are root-owned and read-only to it; within `/app` only `/app/data` is
writable, and a fresh named volume inherits that directory's ownership. The
database stays `OPENNEST_DB=/app/data/nests.db`. The server also needs the
container's writable `/tmp`: multipart uploads over 64 KiB are buffered there.
- `HEALTHCHECK` runs `curl --fail http://127.0.0.1:8090/healthz` (interval 30 s,
timeout 5 s, start period 10 s, retries 3, and a 2 s start interval during the
start period on Docker 25+). curl is in the image only for it.
- A read-only or unwritable data location fails startup: the process exits
rather than serving requests it cannot store.
- Uploads: Kestrel's default 30,000,000-byte request limit covers the whole
multipart request, so an archive must be slightly smaller (the smoke stores a
29,000,000-byte one). A larger request gets 413 and nothing is stored. The
desktop client streams without `Expect: 100-continue`, so it typically reports
a closed connection instead of the 413 status. The save still fails.
Data written by a root-run image or a root-owned bind mount is not writable by
UID 1654, so startup fails. With the operator's approval, change ownership of
that data location only, once, for example
`docker run --rm --user 0 --entrypoint chown -v <volume>:/app/data <image> -R 1654:1654 /app/data`
(or `chown -R 1654:1654` on the bind-mounted directory itself, never its
parents). The image has no root entrypoint that changes ownership.
### Deploying with Compose
`compose.server.yaml` runs a published image (no local build) with
`no-new-privileges`, all capabilities dropped, and a named data volume. Copy it and
`OpenNest.Server/server.env.example` to a deployment directory, save the env file
under a local name such as `server.env`, and set:
- `OPENNEST_SERVER_IMAGE`: `ghcr.io/ajisaacs/opennest-server:X.Y.Z` or, preferably,
`ghcr.io/ajisaacs/opennest-server@sha256:<verified-manifest-digest>` from the
successful clean pulled-image verification job. Do not substitute Docker's
store-dependent image `Id` or the config blob digest for this verified registry
manifest digest; see [server image releases](releasing.md#server-image-separate-from-windows-candidates).
- `OPENNEST_BIND_ADDRESS`: `127.0.0.1` for testing on the host; this host's
trusted LAN address for shop PCs. Do not use `0.0.0.0`. The bind address is only
one layer: verify that the network/firewall admits only trusted clients.
- `OPENNEST_HOST_PORT` (default 8090) and `OPENNEST_DATA_VOLUME` (default `opennest-data`).
Publication requires an existing verified private package; first-package
initialization needs separate owner authorization outside the release workflow.
An authorized deployment operator must obtain a
`read:packages` token out of band and log in on the deployment host using
`docker login ghcr.io -u <github-user> --password-stdin`, piping the token from a
secret manager or a protected prompt (never a token literal in shell history).
Keep Docker's credentials protected on that host; do not put credentials in
`server.env`, Compose, the image, or source control. Login/pull success does not
authorize making the package public. Until an approved image has passed the
published-digest smoke, the reference below is only a template, not an available
verified image.
```sh
compose="docker compose --env-file server.env -f compose.server.yaml"
$compose pull || { printf 'STOP: image pull failed\n' >&2; exit 1; }
$compose up -d
$compose ps # STATUS shows (healthy)
curl --fail http://<bind-address>:<port>/healthz # {"status":"ok"}
```
In the desktop app choose **File > Storage Mode...**, Database, and enter the base
URL `http://<bind-address>:<port>`, without `/healthz` or `/api/nests`.
### Backup, restore, and upgrade
SQLite runs in WAL mode, so copying `nests.db` from a running service is not a
backup. Back up during a quiet period with no saves. Each step below is a Bash
function that checks every command and stops at the first failure, however it is
called; define them in the shell where `compose` is set:
```bash
url=http://<bind-address>:<port>
# "<id> <archive SHA-256>" for every record, sorted by id. Fails on any failed request.
nest_manifest() (
set -o pipefail
list=$(curl -fsS "$1/api/nests") || exit 1
for id in $(printf '%s' "$list" | grep -o '"id":"[0-9a-f-]*"' | cut -d'"' -f4 | sort); do
hash=$(curl -fsS "$1/api/nests/$id/file" | sha256sum) || exit 1
printf '%s %s\n' "$id" "${hash%% *}"
done
)
# Records the expected manifest and metadata, then archives the stopped data directory.
# The service is restarted even if the archive fails.
backup_nests() ( # usage: backup_nests opennest-data-YYYY-MM-DD
set -o pipefail
fail() { echo "STOP: $*" >&2; exit 1; }
[[ ! -e "$1.tar" ]] || fail "$1.tar already exists"
nest_manifest "$url" > "$1.manifest" || fail "could not record the manifest"
curl -fsS "$url/api/nests" > "$1.metadata.json" || fail "could not record the metadata"
$compose stop || fail "could not stop the service"
$compose run --rm --no-deps -T --entrypoint tar opennest-server -C /app/data -cf - . > "$1.tar"
status=$?
$compose start || fail "could not restart the service"
curl -fs --retry 30 --retry-all-errors --retry-delay 1 "$url/healthz" > /dev/null \
|| fail "the restarted service is not healthy"
[[ $status == 0 ]] || fail "the archive failed; $1.tar is incomplete"
echo "Backed up $(wc -l < "$1.manifest") records to $1.tar"
)
backup_nests opennest-data-YYYY-MM-DD
```
Restore only into a volume created for that restore, never into an existing one
(least of all the active volume). `restore_check` refuses an existing volume,
extracts the backup into a new one, starts a temporary loopback-only container on
it, and requires the exact metadata list and every archive hash to match. It
removes only the container it started; the new volume is kept either way:
```bash
restore_check() ( # usage: restore_check opennest-data-YYYY-MM-DD <image> [new-volume]
set -o pipefail
fail() { echo "STOP: $*" >&2; exit 1; }
restore=${3:-opennest-data-restore-$(date +%Y%m%d-%H%M%S)}
! docker volume inspect "$restore" > /dev/null 2>&1 || fail "volume $restore already exists"
docker volume create "$restore" > /dev/null || fail "could not create volume $restore"
echo "Restoring into new volume $restore"
docker run --rm -i --network none --entrypoint tar -v "$restore:/app/data" "$2" \
-C /app/data -xf - < "$1.tar" || fail "extraction into $restore failed"
check=$(docker create -p 127.0.0.1::8090 -v "$restore:/app/data" "$2") \
|| fail "could not create the check container"
trap 'docker rm -f "$check" > /dev/null' EXIT
docker start "$check" > /dev/null || fail "the check container did not start"
port=$(docker port "$check" 8090/tcp | cut -d: -f2) && [[ -n $port ]] || fail "no check port"
curl -fs --retry 30 --retry-all-errors --retry-delay 1 "http://127.0.0.1:$port/healthz" > /dev/null \
|| fail "the restored service is not healthy"
curl -fsS "http://127.0.0.1:$port/api/nests" | cmp - "$1.metadata.json" || fail "metadata differs"
nest_manifest "http://127.0.0.1:$port" | diff - "$1.manifest" || fail "archives differ"
echo "Verified $restore. Set OPENNEST_DATA_VOLUME=$restore in server.env, then run: \$compose up -d"
)
restore_check opennest-data-YYYY-MM-DD <image>
```
Switch only after `restore_check` prints `Verified`. If it stops, inspect or remove
the new volume it named; the active volume is untouched.
To upgrade, take a stopped-service backup and keep a copy of the current
`server.env`. Prefer a digest in `OPENNEST_SERVER_IMAGE` so that file records
exactly what ran; for a tag, record
`docker image inspect --format '{{join .RepoDigests " "}}' <image>` first. Change
only `OPENNEST_SERVER_IMAGE` and run `$compose up -d`. To roll back, restore the
previous `server.env` and run `$compose up -d`; if the new version wrote data the
old one cannot read, also restore the backup as above. Never run
`docker compose down -v`: it deletes the data volume.
## Isolated container smoke
Prerequisites: a local Linux Docker daemon on the default Unix-socket context,
Bash, curl, GNU `timeout`, `mktemp`, `tar`, and a .NET SDK able to build `net8.0` projects
(.NET 8 or newer). Restore needs NuGet access or cached packages. Run from the
repository root after building the image:
```sh
scripts/Test-ServerContainer.sh --image opennest-server:local
# Optional durable logs outside the default ignored .hermes/progress directory:
scripts/Test-ServerContainer.sh --image opennest-server:local --results /path/to/results
```
The wrapper accepts an image, not a URL or an existing volume/container. It uses
only the local default Docker context, creates uniquely labeled disposable
resources, and publishes to `127.0.0.1` on a Docker-allocated port. Every container
runs with the Compose example's `--cap-drop ALL` and `no-new-privileges`. It builds
the smoke tool once into a private temporary directory, honoring `TMPDIR`;
readiness, HTTP requests, and child commands have watchdogs.
Before starting a container it checks the image's numeric non-root `USER`, the
documented `HEALTHCHECK` command and timings (including the start interval), and
the OCI source/version/revision labels. A service is ready only when Docker reports the image's own healthcheck
`healthy` and the host receives `{"status":"ok"}`. Each service then checks that
the server process (PID 1) runs as the image user with no effective capabilities,
owns `nests.db`, and can write `/app/data` but not `/app` or the server DLL.
The test-only `scripts/Server.Tests/Server.Tests.csproj` console exercises the
existing .NET storage client; it is not included in the server image.
The synthetic fixture is generated in code: one plate and one rectangular part,
with no customer files. `seed` uses the actual `RemoteNestRepository` and
`NestSaveSession` to create, update the same ID, and save a copy with a new ID.
It checks read-back metadata, session binding, exact list membership, raw
camelCase/string-enum JSON, identical downloaded bytes, and `NestReader` geometry
and counts. `reject` snapshots every record's metadata and archive hash, then
checks 400 responses for missing/invalid/null metadata and missing/empty files on
both upload and file-update routes; unknown metadata/file IDs must return 404.
Every rejected operation must leave the snapshot unchanged.
`limits` stores, downloads byte-for-byte, and deletes a 29,000,000-byte synthetic
archive (buffered to disk by the non-root runtime), then requires 413 for a
request over 30,000,000 bytes sent with `Expect: 100-continue`, and a reported
failure from the real client's oversized upload and file update. The snapshot
must be unchanged after each.
The persistence stage stops and removes the first container while retaining its
fresh named volume, starts a replacement on another allocated loopback port, and runs
`verify` against saved IDs, **all** metadata (including server-assigned `savedAt`),
archive hashes, and exact list membership. It then follows the documented
stopped-service backup: `tar` of the whole data directory through the image,
restored into a second owned volume whose service must pass the same `verify`.
The original volume mounted read-only, and a root-owned `tmpfs`, must each make
the server exit nonzero at startup with a SQLite error and never report healthy.
A missing/invalid state file or failed assertion exits nonzero. Direct tool use is
test-only: it accepts plain loopback URLs, and `seed` refuses a nonempty server
unless `--test-allow-nonempty` is explicitly supplied for an owned disposable
target. Prefer the wrapper; loopback alone is not proof that an existing server is
disposable.
Each invocation writes a fresh `run.*` results directory containing build, smoke,
container, image, runtime, ownership, and cleanup logs. On failure those logs remain
for diagnosis. The exit trap removes only invocation-owned containers and named
volumes; transient state, the backup archive, synthetic databases, and build
artifacts are removed on success or failure. It never prunes Docker or alters
preexisting services/volumes.
Cleanup requires successful empty Docker inventory readback, including for an
already-removed container. Unresolved ownership, lookup, removal, or readback
errors make the wrapper fail without deleting unproven resources; inspect
`cleanup.log` and `outcome.log` for diagnostics. An original smoke failure is
preserved even if cleanup also fails.
The focused cleanup regression needs only Python 3 and Bash:
```sh
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
```
It executes the wrapper's actual cleanup functions with explicitly mocked Docker
transport, including timeouts and missing cidfiles; it does not replace the real
container smoke or contact a daemon.
+32 -2
View File
@@ -12,7 +12,7 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
| Engine | Best for | Method | | Engine | Best for | Method |
|---|---|---| |---|---|---|
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost | | Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling, six whole-job strategy variants and a tail re-plan | | Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan |
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking | | StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
| Default, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill | | Default, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill |
@@ -25,11 +25,41 @@ A free box may absorb a slightly oversized side only at its right/top edge when
coincides with the plate work-area boundary. Internal leftover edges keep the strict packing coincides with the plate work-area boundary. Internal leftover edges keep the strict packing
tolerance, and actual part dimensions still determine spacing away from the plate boundary. tolerance, and actual part dimensions still determine spacing away from the plate boundary.
Irregular keeps nominal orientation bounds for line-only outlines, so an allowed rotation can
fit exactly between the configured plate-edge gaps. These bounds use original rotated line
endpoints, retaining material extents even when polygon cleanup discards short-edge chains.
Curved outlines retain conservative flattening-error padding. This does not relax part-spacing
footprints, no-fit polygons or layout validation tolerances; a part extending beyond the accepted
work-area bounds remains invalid.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. Concave no-fit polygons are prepared with a single boundary/containment union. enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair
where both members' free regions allow it. Each pair's internal spacing is re-checked with the
layout check before it is offered, and only rotations the part's policy allows are used. A pair
may introduce legal rotations beyond the sampled single poses; these remain eligible even when
none of the sampled singles fits the stock. Both members block space separately, leaving their
notches and intervening gaps available for later parts. Concave no-fit polygons are prepared
with a single boundary/containment union.
Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden
space, preserving genuine enclosed placement pockets without changing spacing tolerances. space, preserving genuine enclosed placement pockets without changing spacing tolerances.
When remaining demand exceeds two, Irregular also offers Default Fill patterns as optional
multi-member candidates, not as solid bounding boxes or a whole-job Default fallback. It searches
the empty work area and physical leftover space for up to two high-area rectangles. Occupied
outlines are expanded by part spacing before rectangle search. Each sheet prepares blocks initially
and after its first placement, for up to four high-demand-area types; each type has at most eight
new Fill preparations per spacing per solve. Repeated rectangles reuse private drawing/candidate
caches. Quantity-one and quantity-two requests never run block Fill.
Block members are trimmed to remaining demand, mapped back to source-frame rotations, and checked
for legal rotations and internal material clearance before competing with singles and pairs.
Group-only rotations do not expand the single-part rotation choices. Placement intersects all
member free regions and subtracts each placed member separately, preserving usable gaps. A failed
or invalid Fill proposal leaves singles and pairs available. Large enclosed-pocket blocks remain
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
remain separate sequencer/verification work.
## Renamed engines ## Renamed engines
Earlier releases shipped these as plug-ins under other names. The registry maps the old names so Earlier releases shipped these as plug-ins under other names. The registry maps the old names so
@@ -0,0 +1,86 @@
# Small synthetic-nest validity gate
From the repository root, with .NET 8 and Python 3.9 or later:
```sh
python3 scripts/check-synthetic-nests.py
```
The runner builds the existing `OpenNest.Benchmark` in Release and invokes it with
`--engines Irregular --parallel 2 --progress`. It prints the evidence directory;
`build.log`, `benchmark.log`, `results.csv` and (only on success) `accepted.json`
are retained there. Use `--output <directory>` to choose a durable location.
Progress is written to `benchmark.log`, not streamed to the terminal.
The entire build + benchmark has a 300-second watchdog. Nonzero subprocess exits,
cancellation and timeout fail the gate. On Unix the runner kills its owned process
session, including descendants; Windows uses `taskkill /T /F`. Build servers are
disabled for this check. `--timeout <seconds>` may shorten, never extend, the budget.
Default concurrency is two; `--parallel 1` is serial and `--parallel 4` is the upper
bound for a machine with adequate resources. Do not use the override to oversubscribe
CI or a busy host.
## The six committed fixtures
These are wholly synthetic, generated from CNC polylines through `Drawing`, `Nest`
and `NestWriter`. No drawing archives or customer jobs are involved. All use inches,
quadrant 1, part spacing 0.25, edge spacing 0.25 on every side, and no salvage credit.
Sheet dimensions below are X length by Y width (the `Size` API takes width first).
| File stem | Synthetic demand | Sheet X × Y | Expected sheets |
| --- | --- | --- | --- |
| `single-triangle` | one 6 × 5 triangle | 20 × 20 | 1 |
| `paired-wedges` | two wedges, vertices (0,0), (8,0), (6,20), (0,20) | 30 × 15 | 1 |
| `repeated-ell-fill` | seven 9 × 7 L shapes, arm thickness 3 | 60 × 40 | 1 |
| `mixed-irregular` | one triangle, two L shapes, three pentagons | 30 × 25 | 1 |
| `rotation-edge-fit` | one 8 × 3 rectangle that must turn to fit | 3.52 × 8.52 | 1 |
| `multi-sheet` | three 8 × 8 squares, only one fits each sheet | 10 × 10 | 3 |
All drawings permit automatic rotation. The edge-fit fixture deliberately leaves
0.02 extra work-area room: it is a tight rotated fit, not an exact-contact test.
The qty-two fixture checks one-sheet interlocking fulfillment; the qty-greater-than-two
fixture exercises a job eligible for Fill-block candidates. This smoke gate does not
assert which internal candidate won; the existing pair/block unit tests retain those
contracts. These are ordinary solid contours, not acceptance of future hole nesting,
containment sequencing or shop-use safety.
To regenerate the six archives through the repository's IO APIs:
```sh
dotnet run --project test-data/synthetic-nests/Generator/Generator.csproj -- test-data/synthetic-nests
```
The generator fixes dates/ZIP timestamps and leaves customer, maker and source-path
metadata empty. Inspect the generated diff before committing. It never reads external
DXFs. Fixtures contain demand and plate defaults, not prepacked layouts.
## Fail-closed result checking
The runner requires exactly the six expected input files, known positive demand and
exactly one Irregular result for each. `Baseline` rows are ignored, not accepted as
engine results. Unknown engines/jobs, duplicates, missing results, empty output,
invalid layouts, crashes, timeout notes, incomplete fulfillment, changed inputs and
unexpected sheet counts all fail. Benchmark's exit code zero alone is not acceptance.
CSV flags must be the exact emitted `True`/`False` values; placed/requested demand
must match the fixture contract. A saved CSV can be checked without solving:
```sh
python3 scripts/check-synthetic-nests.py --check-csv <results.csv>
python3 scripts/test_check_synthetic_nests.py -v
```
The unit checks use labeled synthetic CSV rows and a real watchdog process-tree
probe. The end-to-end command uses actual benchmark results and the production
independent layout validator. This gate supplements, never replaces, the existing
geometry, contract, cancellation, sequencing, posting and cross-platform unit suites.
GitHub's cross-platform workflow runs both the checker tests and the six-fixture gate.
Windows runtime acceptance is separate; a Linux pass does not certify Windows tree
termination or desktop/shop interactions.
## Validity is not speed
Parallel elapsed times are diagnostic only. Serial and parallel runs must account for
the same fixtures and fulfill the same demands; they do not promise identical poses.
Even `--parallel 1` here is a smoke gate, not a controlled performance comparison.
Actual speed claims need serial quiet, interleaved comparisons with frozen inputs and
source provenance. Full customer-corpus audits remain opt-in and uncommitted.
+3 -1
View File
@@ -84,7 +84,9 @@ The overlap diagnostic's documented numeric/tessellation limits also apply.
Future hardening should inspect a shared post-specific emitted motion stream, Future hardening should inspect a shared post-specific emitted motion stream,
including controller retracts and final parking, rather than pretending direct XY including controller retracts and final parking, rather than pretending direct XY
geometry proves physical head clearance. geometry proves physical head clearance. The [fixed-program route foundation](cutting-planner.md)
reuses these motion/completion checks but is not yet a desktop planner or an Apply
API; its route-only `Ready` result does not replace fresh posting review.
## Verification on Windows ## Verification on Windows
+70
View File
@@ -56,3 +56,73 @@ and discovers posts. It does not replace interactive CAD/nesting acceptance,
physical CNC/serial verification, GPU execution, or real-model ONNX accuracy. physical CNC/serial verification, GPU execution, or real-model ONNX accuracy.
Packages are unsigned; code signing and a fuller packaged-post execution test Packages are unsigned; code signing and a fuller packaged-post execution test
remain follow-up hardening. remain follow-up hardening.
## Server image (separate from Windows candidates)
`Server image` validates relevant PRs and `master` pushes without registry login,
package-write permissions, or registry upload. It builds/tests with .NET 8, records pinned
SDK/runtime base digests, builds a single-platform `linux/amd64` image without cache
or attestations (`--provenance=false --sbom=false`), and runs the real-client
container persistence/backup/restore smoke. A Windows `v*` tag build alone never
publishes a server image.
Publication requires owner-approved release intent: a **published GitHub Release**
or an explicit `Server image` dispatch **from `master`**, naming an existing strict
`vX.Y.Z` tag (no prerelease, leading zero, or extra suffix). The tag must resolve to
a full commit already on `master`. For a published Release, that peeled commit
must also equal the event's full `GITHUB_SHA`; a retargeted tag is refused. Manual
dispatch intentionally resolves the approved existing tag, not the workflow's
`master` SHA. The job checks out that exact source, reruns all
server/image gates, and pushes the *same smoked image* to
`ghcr.io/ajisaacs/opennest-server:X.Y.Z` and `:sha-<full-commit>` only. Both tags must
be absent; retries after even a partial upload stop instead of overwriting. There
are no `latest`, major, or minor aliases. Do not dispatch just to test publication.
Publication requires an **existing owner-verifiable private package**, linked to
`ajisaacs/OpenNest`, with repository Actions access (normally inherited from the
link). The job uses only its scoped `GITHUB_TOKEN`. All package metadata 404s are
refused: GitHub can mask an inaccessible private package as `Not Found`. Opaque
registry token success, an empty tag list, or a registry 404 proves neither package
absence nor privacy and cannot override the metadata check. Explicit reduced
scope and failed registry read access also stop the job. The job rechecks private
association after upload.
First-package initialization is a separately owner-authorized prerequisite
outside this workflow. Until the private package, repository link, and Actions
access can be verified, publication remains blocked while read-only validation
can pass. There
is no automatic bootstrap, extra PAT, absence assertion, or bypass setting. A
local credential's Packages API 403 proves neither absence nor privacy. No
workflow changes visibility. Public availability needs separate owner approval
and a later anonymous-pull check; it is not approved here.
The local gate reads `docker image save` to hash the actual config and verify each
layer against its ordered uncompressed rootfs digest. It rejects unexpected
indexes/attestations. Smoke and tagging use the inspected immutable Docker ID,
not a mutable local tag, and readback must match that pre-smoke identity. Success
requires exact readback of both manifest digests, `linux/amd64`, OCI labels, config
bytes, and the full layer/rootfs chain identifying that same smoked image.
A **separate clean job** pulls the returned manifest digest, independently checks
its saved config/layers, and repeats the real-client smoke using tools checked out
from that source commit. Only its pass verifies the image.
Artifacts retain TRX, explicit logs, and safe provenance. Docker's store-dependent
`Id`, the config blob digest, and the registry manifest digest are separate values:
classic Docker may use the config digest as `Id`, while containerd may use a
manifest or index digest. Disabling attestations does not make `Id` a config
digest. Temporary image-save archives are deleted, never uploaded. Artifacts
never include smoke state JSON, databases, nest archives, Docker auth configs, or
credentials.
A failed publication/verification is not a release acceptance; inspect its logs
and any partial tags with the owner before choosing a new approved version.
For a local read-only check of the release guards:
```sh
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
```
Deployment is deliberately separate; use the verified digest and the
[private pull/Compose procedure](nest-storage.md#deploying-with-compose). No
publication, release creation, deployment, or visibility change is implied by
adding or validating this workflow.
+484
View File
@@ -0,0 +1,484 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Data;
using OpenNest.Geometry;
using OpenNest.IO;
using CncProgram = OpenNest.CNC.Program;
namespace OpenNest.Server.Tests;
internal static class Program
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private static async Task<int> Main(string[] args)
{
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
try
{
var options = Options.Parse(args);
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(90));
using var handler = new WireContractHandler();
using var http = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(10),
// Only the limits mode downloads its near-cap synthetic archive.
MaxResponseContentBufferSize = (options.Mode == "limits" ? 32 : 16) * 1024 * 1024,
};
using var repository = new RemoteNestRepository(http, options.Url);
switch (options.Mode)
{
case "seed":
await Seed(repository, options, watchdog.Token);
break;
case "verify":
await Verify(repository, options, watchdog.Token);
break;
case "reject":
await Reject(repository, http, options.Url, watchdog.Token);
break;
case "limits":
await Limits(repository, http, options.Url, watchdog.Token);
break;
}
Console.WriteLine($"PASS {options.Mode}; raw camelCase/string-enum responses checked: {handler.CheckedResponses}");
return 0;
}
catch (SmokeAssertionException ex)
{
Console.Error.WriteLine($"FAIL: {ex.Message}");
return 1;
}
catch (Exception ex)
{
// Do not print URLs, response bodies, or metadata from an accidental target.
Console.Error.WriteLine($"FAIL: {ex.GetType().Name}; smoke did not complete.");
return 1;
}
}
private static async Task Seed(RemoteNestRepository repository, Options options, CancellationToken ct)
{
var existing = await repository.ListAsync(ct);
Check(existing.Count == 0 || options.AllowNonempty,
"Seed refuses a nonempty server; use only an owned disposable server.");
var baseline = await Snapshot(repository, ct);
var nest = SyntheticNest();
var session = new NestSaveSession();
Check(session.RemoteId == Guid.Empty && session.ServerUrl == "", "New session must be unbound.");
var archive = Archive(nest);
var created = await session.SaveAsync(repository, options.Url, nest, archive, cancellationToken: ct);
Check(created.Id != Guid.Empty, "Create must return a nonempty id.");
CheckBinding(session, created.Id, options.Url);
await ReadSaved(repository, nest, archive, created, ct);
await CheckMembership(repository, baseline.Select(s => s.Metadata.Id).Append(created.Id), ct);
nest.Name = "Synthetic container smoke updated";
nest.Customer = "Synthetic test customer updated";
nest.Notes = "Updated neutral smoke metadata";
nest.MadeBy = "Synthetic smoke editor";
nest.Material = new Material("Synthetic alloy updated", "test-grade", 0.3);
nest.Thickness = 0.375;
nest.Status = NestStatus.ToBeCut;
nest.Plates[0].Parts[0].Location = new Vector(4, 5);
archive = Archive(nest);
var updated = await session.SaveAsync(repository, options.Url, nest, archive, cancellationToken: ct);
Check(updated.Id == created.Id, "Update must retain the create id.");
CheckBinding(session, created.Id, options.Url);
var updateState = await ReadSaved(repository, nest, archive, updated, ct);
await CheckMembership(repository, baseline.Select(s => s.Metadata.Id).Append(created.Id), ct);
nest.Name = "Synthetic container smoke copy";
nest.Status = NestStatus.HasBeenCut;
archive = Archive(nest);
var copy = await session.SaveAsync(repository, options.Url, nest, archive, saveCopy: true,
cancellationToken: ct);
Check(copy.Id != Guid.Empty && copy.Id != created.Id, "Save copy must create a different id.");
CheckBinding(session, copy.Id, options.Url);
var copyState = await ReadSaved(repository, nest, archive, copy, ct);
var entries = baseline.Concat(new[] { updateState, copyState }).ToList();
await CheckSnapshot(repository, entries, ct);
var state = new SmokeState { Version = 1, Records = entries };
await File.WriteAllTextAsync(options.State!, JsonSerializer.Serialize(state, JsonOptions), ct);
Console.WriteLine($"Seed stored {entries.Count} records; create/update id={updated.Id}; copy id={copy.Id}");
foreach (var entry in new[] { updateState, copyState })
Console.WriteLine($"Archive id={entry.Metadata.Id}; bytes={entry.Metadata.FileSize}; sha256={entry.Sha256}");
}
private static async Task Verify(RemoteNestRepository repository, Options options, CancellationToken ct)
{
Check(File.Exists(options.State), "Verify state is missing.");
var state = JsonSerializer.Deserialize<SmokeState>(await File.ReadAllTextAsync(options.State!, ct), JsonOptions);
Check(state is { Version: 1, Records.Count: >= 2 }, "Verify state is invalid.");
var entries = state!.Records;
Check(entries.All(e => e is not null && e.Metadata is not null && e.Metadata.Id != Guid.Empty
&& e.Metadata.SavedAt != default && e.Metadata.FileSize > 0
&& e.Sha256 is not null && e.Sha256.Length == 64 && e.Sha256.All(Uri.IsHexDigit)),
"Verify state contains invalid metadata or hashes.");
Check(entries.Select(e => e.Metadata.Id).Distinct().Count() == entries.Count,
"Verify state contains duplicate ids.");
await CheckSnapshot(repository, entries, ct);
Console.WriteLine($"Verified {entries.Count} persisted records, all metadata including SavedAt, and archive hashes.");
}
private static async Task Reject(RemoteNestRepository repository, HttpClient http, string url, CancellationToken ct)
{
var before = await Snapshot(repository, ct);
Check(before.Count > 0, "Rejection checks require seeded records on an owned server.");
var metadata = JsonSerializer.Serialize(new NestRecord { Name = "Synthetic rejected upload" }, JsonOptions);
var cases = new (string Name, string? Metadata, byte[]? File)[]
{
("missing metadata", null, new byte[] { 1 }),
("invalid metadata JSON", "{not-json", new byte[] { 1 }),
("null metadata", "null", new byte[] { 1 }),
("missing file", metadata, null),
("empty file", metadata, Array.Empty<byte>()),
};
// Exercise both shared multipart callers, including mutation of an existing record.
foreach (var method in new[] { HttpMethod.Post, HttpMethod.Put })
{
var path = method == HttpMethod.Post ? "api/nests" : $"api/nests/{before[0].Metadata.Id}/file";
foreach (var test in cases)
{
using var content = new MultipartFormDataContent();
if (test.Metadata is not null)
content.Add(new StringContent(test.Metadata), "metadata");
if (test.File is not null)
content.Add(new ByteArrayContent(test.File), "file", "synthetic.nest");
using var request = new HttpRequestMessage(method, new Uri(new Uri(url + "/"), path)) { Content = content };
using var response = await http.SendAsync(request, ct);
Check(response.StatusCode == HttpStatusCode.BadRequest, $"{method} {test.Name} must return 400.");
await CheckSnapshot(repository, before, ct);
Console.WriteLine($"Rejected {method} {test.Name}: 400; all metadata and hashes unchanged.");
}
}
var unknown = Guid.NewGuid();
Check(before.All(e => e.Metadata.Id != unknown), "Unknown-id fixture collided.");
Check(await repository.GetMetadataAsync(unknown, ct) is null, "Unknown metadata id must return 404.");
Check(await repository.GetFileAsync(unknown, ct) is null, "Unknown file id must return 404.");
await CheckSnapshot(repository, before, ct);
Console.WriteLine("Unknown metadata/file ids: 404; all metadata and hashes unchanged.");
}
// Kestrel's default MaxRequestBodySize (30,000,000 bytes) bounds a whole multipart request.
private const int RequestBodyLimit = 30_000_000;
private static async Task Limits(RemoteNestRepository repository, HttpClient http, string url, CancellationToken ct)
{
var before = await Snapshot(repository, ct);
Check(before.Count > 0, "Limit checks require seeded records on an owned server.");
// Opaque synthetic bytes (the server does not parse archives) above the 64 KiB form
// memory threshold, so the non-root runtime must buffer the part to its temp directory.
var accepted = new byte[29_000_000];
RandomNumberGenerator.Fill(accepted);
var created = await repository.UploadAsync(accepted, new NestRecord
{
Name = "Synthetic upload-limit probe",
Comments = "Random bytes; deleted by the same smoke stage",
}, ct);
Check(created.Id != Guid.Empty && created.FileSize == accepted.LongLength,
"A near-limit upload must be stored with its exact size.");
var downloaded = await repository.GetFileAsync(created.Id, ct);
Check(downloaded is not null && downloaded.AsSpan().SequenceEqual(accepted),
"A near-limit archive must download byte-for-byte.");
await CheckMembership(repository, before.Select(s => s.Metadata.Id).Append(created.Id), ct);
await repository.DeleteAsync(created.Id, ct);
await CheckSnapshot(repository, before, ct);
Console.WriteLine($"Accepted, downloaded, and deleted a {accepted.Length}-byte archive; all other records unchanged.");
// Kestrel rejects an oversized declared length before the app reads the form. With
// Expect: 100-continue the client waits for, and must receive, that 413 response.
var oversized = new byte[RequestBodyLimit];
var oversizedRecord = new NestRecord { Name = "Synthetic oversized upload" };
var metadata = JsonSerializer.Serialize(oversizedRecord, JsonOptions);
foreach (var method in new[] { HttpMethod.Post, HttpMethod.Put })
{
var path = method == HttpMethod.Post ? "api/nests" : $"api/nests/{before[0].Metadata.Id}/file";
using var content = new MultipartFormDataContent();
content.Add(new StringContent(metadata), "metadata");
content.Add(new ByteArrayContent(oversized), "file", "synthetic.nest");
using var request = new HttpRequestMessage(method, new Uri(new Uri(url + "/"), path)) { Content = content };
request.Headers.ExpectContinue = true;
using var response = await http.SendAsync(request, ct);
Check(response.StatusCode == HttpStatusCode.RequestEntityTooLarge,
$"{method} of a request over {RequestBodyLimit} bytes must return 413.");
await CheckSnapshot(repository, before, ct);
Console.WriteLine($"Rejected {method} over-limit request: 413; all metadata and hashes unchanged.");
}
// RemoteNestRepository streams without Expect: 100-continue, so the server may close the
// connection before the client reads the 413. The real client must still report failure.
var saves = new (string Name, Func<Task> Save)[]
{
("upload", () => repository.UploadAsync(oversized, oversizedRecord, ct)),
("file update", () => repository.UpdateFileAsync(before[0].Metadata.Id, oversized, oversizedRecord, ct)),
};
foreach (var (name, save) in saves)
{
var outcome = "succeeded";
try
{
await save();
}
catch (Exception ex) when (ex is IOException or HttpRequestException)
{
outcome = ex.GetType().Name;
}
Check(outcome != "succeeded", $"Real-client oversized {name} must fail.");
await CheckSnapshot(repository, before, ct);
Console.WriteLine($"Real-client oversized {name} failed ({outcome}); all metadata and hashes unchanged.");
}
}
private static async Task<SavedRecord> ReadSaved(RemoteNestRepository repository, Nest nest,
byte[] archive, NestRecord returned, CancellationToken ct)
{
Check(returned.SavedAt != default, "Server must assign SavedAt.");
var expected = NestRecordFactory.FromNest(nest, returned.Id, archive.LongLength);
expected.SavedAt = returned.SavedAt;
CheckMetadata(expected, returned);
var readback = await repository.GetMetadataAsync(returned.Id, ct);
Check(readback is not null, "Saved metadata must be readable.");
CheckMetadata(expected, readback!);
var downloaded = await repository.GetFileAsync(returned.Id, ct);
Check(downloaded is not null && downloaded.SequenceEqual(archive), "Downloaded archive bytes must match upload.");
var geometry = Geometry(nest);
CheckArchive(downloaded!, readback!, geometry);
return new SavedRecord { Metadata = readback!, Sha256 = Hash(downloaded!), Geometry = geometry };
}
private static async Task<List<SavedRecord>> Snapshot(RemoteNestRepository repository, CancellationToken ct)
{
var records = await repository.ListAsync(ct);
Check(records.Count <= 100, "Smoke snapshot refuses more than 100 records.");
Check(records.Select(r => r.Id).Distinct().Count() == records.Count, "List ids must be unique.");
var snapshot = new List<SavedRecord>();
foreach (var record in records.OrderBy(r => r.Id))
{
var readback = await repository.GetMetadataAsync(record.Id, ct);
Check(readback is not null, "Snapshot metadata must be readable.");
CheckMetadata(record, readback!);
var file = await repository.GetFileAsync(record.Id, ct);
Check(file is not null && file.LongLength == record.FileSize, "Snapshot archive size must match metadata.");
snapshot.Add(new SavedRecord { Metadata = readback!, Sha256 = Hash(file!) });
}
return snapshot;
}
private static async Task CheckSnapshot(RemoteNestRepository repository,
IReadOnlyList<SavedRecord> expected, CancellationToken ct)
{
await CheckMembership(repository, expected.Select(e => e.Metadata.Id), ct);
var actual = await Snapshot(repository, ct);
foreach (var entry in expected)
{
var readback = actual.Single(e => e.Metadata.Id == entry.Metadata.Id);
CheckMetadata(entry.Metadata, readback.Metadata);
Check(entry.Sha256 == readback.Sha256, "Persisted archive SHA256 must match state.");
if (entry.Geometry is not null)
{
var archive = await repository.GetFileAsync(entry.Metadata.Id, ct);
Check(archive is not null, "Persisted archive must be readable.");
CheckArchive(archive!, entry.Metadata, entry.Geometry);
}
}
}
private static async Task CheckMembership(RemoteNestRepository repository,
IEnumerable<Guid> ids, CancellationToken ct)
{
var expected = ids.Order().ToArray();
var actual = (await repository.ListAsync(ct)).Select(r => r.Id).Order().ToArray();
Check(expected.SequenceEqual(actual), "List must contain exactly the expected ids.");
}
private static void CheckMetadata(NestRecord expected, NestRecord actual) =>
Check(JsonSerializer.Serialize(expected, JsonOptions) == JsonSerializer.Serialize(actual, JsonOptions),
"All metadata fields must round-trip exactly.");
private static void CheckBinding(NestSaveSession session, Guid id, string url) =>
Check(session.RemoteId == id && session.ServerUrl == url, "Session must bind to the saved id and server URL.");
private static Nest SyntheticNest()
{
var program = new CncProgram();
program.MoveTo(0, 0);
program.LineTo(6, 0);
program.LineTo(6, 3);
program.LineTo(0, 3);
program.LineTo(0, 0);
var drawing = new Drawing("Synthetic rectangle", program);
drawing.Quantity.Required = 1;
var nest = new Nest("Synthetic container smoke")
{
Customer = "Synthetic test customer",
DateCreated = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified),
Material = new Material("Synthetic alloy", "test-grade", 0.25),
Thickness = 0.25,
Status = NestStatus.Quote,
Notes = "Neutral generated fixture; no customer data",
MadeBy = "Synthetic smoke author",
Units = Units.Inches,
};
nest.Drawings.Add(drawing);
var plate = new Plate(new Size(20, 30)) { Quantity = 1 };
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
nest.Plates.Add(plate);
Check(drawing.Area == 18, "Synthetic rectangle must have closed area 18.");
return nest;
}
private static byte[] Archive(Nest nest)
{
using var stream = new MemoryStream();
Check(new NestWriter(nest).Write(stream), "NestWriter must succeed.");
return stream.ToArray();
}
private static GeometryState Geometry(Nest nest) => new()
{
PlateWidth = nest.Plates[0].Size.Width,
PlateLength = nest.Plates[0].Size.Length,
PartX = nest.Plates[0].Parts[0].Location.X,
PartY = nest.Plates[0].Parts[0].Location.Y,
DrawingArea = nest.Drawings.Single().Area,
};
private static void CheckArchive(byte[] archive, NestRecord metadata, GeometryState geometry)
{
using var stream = new MemoryStream(archive, writable: false);
var reader = new NestReader(stream);
var nest = reader.Read();
Check(reader.Warnings.Count == 0, "NestReader must parse without warnings.");
var parsed = NestRecordFactory.FromNest(nest, metadata.Id, archive.LongLength);
parsed.SavedAt = metadata.SavedAt;
CheckMetadata(metadata, parsed);
Check(nest.Drawings.Count == 1 && nest.Plates.Count == 1 && nest.Plates[0].Parts.Count == 1,
"Parsed archive must contain one drawing, plate, and part.");
Check(nest.Drawings.Single().Quantity.Required == 1 && nest.Drawings.Single().Quantity.Nested == 1,
"Parsed drawing quantities must match.");
Check(nest.Plates[0].Size.Width == geometry.PlateWidth && nest.Plates[0].Size.Length == geometry.PlateLength
&& nest.Plates[0].Parts[0].Location.X == geometry.PartX && nest.Plates[0].Parts[0].Location.Y == geometry.PartY
&& nest.Drawings.Single().Area == geometry.DrawingArea && nest.Drawings.Single().Program.Codes.Count == 5,
"Parsed plate/rectangle geometry must match.");
}
private static string Hash(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes));
private static void Check(bool condition, string message)
{
if (!condition)
throw new SmokeAssertionException(message);
}
private sealed class SmokeAssertionException(string message) : Exception(message);
private sealed record Options(string Mode, string Url, string? State, bool AllowNonempty)
{
public static Options Parse(string[] args)
{
Check(args.Length > 0 && args[0] is "seed" or "verify" or "reject" or "limits",
"Mode must be seed, verify, reject, or limits.");
string? url = null;
string? state = null;
var allowNonempty = false;
for (var i = 1; i < args.Length; i++)
{
if (args[i] == "--url" && url is null && i + 1 < args.Length)
url = args[++i];
else if (args[i] == "--state" && state is null && i + 1 < args.Length)
state = args[++i];
else if (args[i] == "--test-allow-nonempty" && !allowNonempty)
allowNonempty = true;
else
throw new SmokeAssertionException("Invalid or duplicate smoke argument.");
}
Check(Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttp
&& uri.Host == "127.0.0.1" && uri.Port > 0 && uri.AbsolutePath == "/"
&& uri.UserInfo == "" && uri.Query == "" && uri.Fragment == "",
"Smoke requires a plain http://127.0.0.1:<port> URL without credentials, path, query, or fragment.");
Check(args[0] is "reject" or "limits" ? state is null && !allowNonempty : !string.IsNullOrWhiteSpace(state),
"Seed/verify require --state; reject/limits do not accept state or an override.");
Check(args[0] == "seed" || !allowNonempty, "Nonempty override is TEST-only and seed-only.");
return new Options(args[0], uri!.GetLeftPart(UriPartial.Authority), state, allowNonempty);
}
}
private sealed class SmokeState
{
public int Version { get; set; }
public List<SavedRecord> Records { get; set; } = new();
}
private sealed class SavedRecord
{
public NestRecord Metadata { get; set; } = new();
public string Sha256 { get; set; } = "";
public GeometryState? Geometry { get; set; }
}
private sealed class GeometryState
{
public double PlateWidth { get; set; }
public double PlateLength { get; set; }
public double PartX { get; set; }
public double PartY { get; set; }
public double DrawingArea { get; set; }
}
// Observe the real repository's responses; do not reimplement its HTTP endpoints.
private sealed class WireContractHandler : DelegatingHandler
{
private static readonly string[] Fields = typeof(NestRecord).GetProperties()
.Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).Order().ToArray();
public int CheckedResponses { get; private set; }
public WireContractHandler() : base(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false }) { }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var response = await base.SendAsync(request, ct);
try
{
if (response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.NoContent
&& !(request.Method == HttpMethod.Get
&& request.RequestUri!.AbsolutePath.EndsWith("/file", StringComparison.Ordinal)))
{
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
if (json.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var record in json.RootElement.EnumerateArray())
CheckWireRecord(record);
}
else
CheckWireRecord(json.RootElement);
CheckedResponses++;
}
return response;
}
catch
{
response.Dispose();
throw;
}
}
private static void CheckWireRecord(JsonElement record)
{
Check(record.ValueKind == JsonValueKind.Object
&& record.EnumerateObject().Select(p => p.Name).Order().SequenceEqual(Fields),
"Raw JSON must contain exactly all camelCase metadata fields.");
var status = record.GetProperty("status");
Check(status.ValueKind == JsonValueKind.String
&& status.GetString() is "quote" or "toBeCut" or "hasBeenCut", "Raw JSON status must be a camelCase string enum.");
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Server.Tests</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../OpenNest.Data/OpenNest.Data.csproj" />
<ProjectReference Include="../../OpenNest.IO/OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env bash
# Isolated local-only smoke; never accepts an existing container, volume, or URL.
set -Eeuo pipefail
umask 077
# A whole-run bound also leaves time for the exit trap before an outer CI watchdog.
if [[ ${OPENNEST_SMOKE_WATCHDOG:-} != 1 ]]; then
command -v timeout >/dev/null || { printf 'Missing prerequisite: timeout\n' >&2; exit 2; }
exec timeout --signal=TERM --kill-after=30s 600s env OPENNEST_SMOKE_WATCHDOG=1 \
bash "${BASH_SOURCE[0]}" "$@"
fi
usage() {
printf 'Usage: %s --image <local-image> [--results <directory>]\n' "$0" >&2
}
image=''
results=''
while (($#)); do
case "$1" in
--image) [[ $# -ge 2 && -z "$image" ]] || { usage; exit 2; }; image=$2; shift 2 ;;
--results) [[ $# -ge 2 && -z "$results" ]] || { usage; exit 2; }; results=$2; shift 2 ;;
*) usage; exit 2 ;;
esac
done
[[ -n "$image" && "$image" != -* ]] || { usage; exit 2; }
for command in docker dotnet curl timeout mktemp tar; do
command -v "$command" >/dev/null || { printf 'Missing prerequisite: %s\n' "$command" >&2; exit 2; }
done
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# Honor TMPDIR, including all dotnet build outputs and transient synthetic state.
tmp_root=${TMPDIR:-"$HOME/.cache"}
mkdir -p -- "$tmp_root"
results=${results:-"$root/.hermes/progress/server-container-smoke"}
mkdir -p -- "$results"
# Set up logs before allocating scratch, so results-path failures cannot leak it.
# Each invocation gets a private child directory; existing result files are never overwritten.
run_results=$(mktemp -d "$results/run.XXXXXXXXXX")
scratch=$(mktemp -d "$tmp_root/opennest-server-smoke.XXXXXXXXXX")
token=${scratch##*/}
owner_label='com.opennest.server-smoke.owner'
# Two service generations on one data volume, a stopped-service backup and restore into
# a second volume, its service, then two storage locations that must fail startup.
container_roles=(first replacement backup restore restored readonly unwritable)
container_names=()
cidfiles=()
container_attempted=()
for role in "${container_roles[@]}"; do
container_names+=("$token-$role")
cidfiles+=("$scratch/$role.cid")
container_attempted+=(false)
done
volumes=("$token-data" "$token-restore-data")
volume_attempted=(false false)
# Explicit local default context, ignoring environment overrides for remote daemons.
docker_local() { timeout 20s env -u DOCKER_HOST -u DOCKER_CONTEXT docker --context default "$@"; }
owned_container() {
[[ $(docker_local inspect --format "{{index .Config.Labels \"$owner_label\"}}" "$1") == "$token" ]]
}
generated_name() {
local name
for name in "${container_names[@]}"; do
[[ "$1" == "$name" ]] && return 0
done
return 1
}
cleanup_absent() {
local kind=$1 target=$2 remaining filter
local -a inventory
if [[ "$kind" == container ]]; then
filter="id=$target"
if generated_name "$target"; then
# Generated names contain only alphanumerics, hyphens, and dots.
filter="name=^/${target//./\\.}$"
fi
inventory=(container ls --all --quiet --no-trunc --filter "$filter")
else
inventory=(volume ls --quiet --filter "name=$target")
fi
# A failed inspect is not absence. Only a successful, empty inventory proves it.
if remaining=$(docker_local "${inventory[@]}" 2>> "$run_results/cleanup.log"); then
if [[ -z "$remaining" ]]; then
printf 'Confirmed absent: %s %s\n' "$kind" "$target" >> "$run_results/cleanup.log"
return 0
fi
printf 'Cleanup unknown/incomplete: %s %s remains in inventory: %s\n' \
"$kind" "$target" "$remaining" >> "$run_results/cleanup.log"
else
printf 'Cleanup unknown: %s %s inventory failed status=%s\n' \
"$kind" "$target" "$?" >> "$run_results/cleanup.log"
fi
return 1
}
cleanup_resource() {
local kind=$1 target=$2 index=${3:-} metadata resource owner
local -a inspect remove
if [[ "$kind" == container ]]; then
inspect=(inspect --format "{{.Id}} {{index .Config.Labels \"$owner_label\"}}")
remove=(rm -f)
else
inspect=(volume inspect --format "{{.Name}} {{index .Labels \"$owner_label\"}}")
remove=(volume rm)
fi
if metadata=$(docker_local "${inspect[@]}" "$target" 2>> "$run_results/cleanup.log"); then
read -r resource owner <<< "$metadata"
if [[ -z "$resource" || "$owner" != "$token" ]]; then
printf 'Cleanup unknown: %s %s owner could not be reconciled; refusing removal.\n' \
"$kind" "$target" >> "$run_results/cleanup.log"
return 1
fi
if [[ "$kind" == container ]]; then
# Name recovery resolves a full owned ID before removal, even without a cidfile.
if [[ ! "$resource" =~ ^[0-9a-f]{64}$ ]] \
|| { ! generated_name "$target" && [[ "$resource" != "$target" ]]; }; then
printf 'Cleanup unknown: container %s identity mismatch; refusing removal.\n' \
"$target" >> "$run_results/cleanup.log"
return 1
fi
docker_local logs "$resource" > "$run_results/container-$index.log" 2>&1
docker_local inspect --format 'id={{.Id}} running={{.State.Running}} exit={{.State.ExitCode}}' \
"$resource" > "$run_results/container-$index-state.log" 2>&1
elif [[ "$resource" != "$target" ]]; then
printf 'Cleanup unknown: volume %s identity mismatch; refusing removal.\n' \
"$target" >> "$run_results/cleanup.log"
return 1
fi
docker_local "${remove[@]}" "$resource" >> "$run_results/cleanup.log" 2>&1 || {
printf 'Cleanup incomplete: %s %s removal failed status=%s\n' \
"$kind" "$resource" "$?" >> "$run_results/cleanup.log"
return 1
}
cleanup_absent "$kind" "$resource"
else
printf 'Cleanup lookup failed: %s %s status=%s; checking absence.\n' \
"$kind" "$target" "$?" >> "$run_results/cleanup.log"
cleanup_absent "$kind" "$target"
fi
}
cleanup() {
local status=$? cleanup_failed=0 cid index
trap - EXIT INT TERM
set +e
for index in "${!container_names[@]}"; do
[[ ${container_attempted[$index]} == true ]] || continue
cid=''
if [[ -s ${cidfiles[$index]} ]]; then
read -r cid < "${cidfiles[$index]}" || [[ -n "$cid" ]]
fi
cleanup_resource container "${cid:-${container_names[$index]}}" "$index" || cleanup_failed=1
done
# Docker volume create may return a preexisting name: delete only a matching owner label.
for index in "${!volumes[@]}"; do
[[ ${volume_attempted[$index]} == true ]] || continue
cleanup_resource volume "${volumes[$index]}" || cleanup_failed=1
done
rm -rf -- "$scratch"
if ((cleanup_failed != 0 && status == 0)); then status=1; fi
printf 'exit=%s cleanup_failed=%s\n' "$status" "$cleanup_failed" > "$run_results/outcome.log"
printf 'Smoke exit=%s; logs: %s\n' "$status" "$run_results"
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fail() { printf '%s\n' "$1" >&2; exit 1; }
endpoint=$(docker_local context inspect default --format '{{.Endpoints.docker.Host}}')
[[ "$endpoint" == 'unix:///var/run/docker.sock' ]] || { printf 'Refusing a non-local default Docker endpoint.\n' >&2; exit 2; }
docker_local info --format 'daemon={{.Name}} os={{.OSType}} server={{.ServerVersion}}' > "$run_results/daemon.log"
# Image contract: numeric non-root user, the HTTP healthcheck, and the source label.
docker_local image inspect --format 'image={{.Id}}' "$image" > "$run_results/image.log"
image_user=$(docker_local image inspect --format '{{.Config.User}}' "$image")
healthcheck=$(docker_local image inspect --format '{{.Config.Healthcheck.Test}} {{.Config.Healthcheck.Interval}} {{.Config.Healthcheck.Timeout}} {{.Config.Healthcheck.StartPeriod}} {{.Config.Healthcheck.StartInterval}} {{.Config.Healthcheck.Retries}}' "$image")
labels=$(docker_local image inspect --format '{{index .Config.Labels "org.opencontainers.image.source"}} {{index .Config.Labels "org.opencontainers.image.version"}} {{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")
printf 'user=%s\nhealthcheck=%s\nsource/version/revision=%s\n' "$image_user" "$healthcheck" "$labels" >> "$run_results/image.log"
[[ "$image_user" =~ ^[1-9][0-9]*$ ]] || fail 'Image must declare a numeric non-root USER.'
[[ "$healthcheck" == '[CMD curl --fail --silent --show-error http://127.0.0.1:8090/healthz] 30s 5s 10s 2s 3' ]] \
|| fail 'Image HEALTHCHECK does not match the documented probe.'
[[ "$labels" == 'https://github.com/ajisaacs/OpenNest '?*' '?* ]] || fail 'Image OCI source/version/revision labels are missing.'
printf 'host_sdk=%s\n' "$(dotnet --version)" > "$run_results/toolchain.log"
printf 'owner=%s volumes=%s\n' "$token" "${volumes[*]}" > "$run_results/ownership.log"
timeout 180s dotnet build "$root/scripts/Server.Tests/Server.Tests.csproj" -c Release \
--artifacts-path "$scratch/build" -m:1 /nodeReuse:false > "$run_results/build.log" 2>&1
dll="$scratch/build/bin/Server.Tests/release/Server.Tests.dll"
[[ -f "$dll" ]] || fail 'Smoke build did not produce the expected DLL.'
create_volume() {
local index=$1 name=${volumes[$1]}
# Refuse collisions before creation; the post-create label is the authoritative ownership proof.
if docker_local volume inspect "$name" >/dev/null 2>&1; then
printf 'Refusing a preexisting volume name.\n' >&2; return 1
fi
volume_attempted[$index]=true
docker_local volume create --label "$owner_label=$token" "$name" >> "$run_results/volume.log"
[[ $(docker_local volume inspect --format "{{index .Labels \"$owner_label\"}}" "$name") == "$token" ]] \
|| { printf 'Volume ownership could not be established.\n' >&2; return 1; }
}
# Sets the global cid. Every container gets the Compose example's privilege hardening.
create_container() {
local index=$1
shift
if docker_local container inspect "${container_names[$index]}" >/dev/null 2>&1; then
printf 'Refusing a preexisting container name.\n' >&2; return 1
fi
container_attempted[$index]=true
docker_local create --name "${container_names[$index]}" --cidfile "${cidfiles[$index]}" \
--label "$owner_label=$token" --cap-drop ALL --security-opt no-new-privileges:true \
"$@" > "$run_results/create-$index.log"
cid=''
read -r cid < "${cidfiles[$index]}" || [[ -n "$cid" ]]
owned_container "$cid" || { printf 'Container ownership could not be established.\n' >&2; return 1; }
printf 'index=%s name=%s id=%s\n' "$index" "${container_names[$index]}" "$cid" >> "$run_results/ownership.log"
}
# PID 1 itself, not merely an exec session, must be the image user without capabilities.
check_runtime() {
local index=$1 key a b c d uids='' cap_eff='' no_new_privs='' db_owner
docker_local exec "$cid" cat /proc/1/status > "$scratch/status-$index"
while IFS=$'\t' read -r key a b c d; do
case "$key" in
Uid:) uids="$a $b $c $d" ;;
CapEff:) cap_eff=$a ;;
NoNewPrivs:) no_new_privs=$a ;;
esac
done < "$scratch/status-$index"
db_owner=$(docker_local exec "$cid" stat -c '%u' /app/data/nests.db)
printf 'index=%s uids=%s cap_eff=%s no_new_privs=%s db_owner=%s\n' \
"$index" "$uids" "$cap_eff" "$no_new_privs" "$db_owner" >> "$run_results/runtime.log"
[[ "$uids" == "$image_user $image_user $image_user $image_user" ]] || fail 'Server process is not the image user.'
[[ "$cap_eff" =~ ^0+$ && "$no_new_privs" == 1 ]] || fail 'Server process retained capabilities or privilege escalation.'
[[ "$db_owner" == "$image_user" ]] || fail 'Database is not owned by the image user.'
docker_local exec "$cid" sh -c 'test -w /app/data && test ! -w /app && test ! -w /app/OpenNest.Server.dll' \
|| fail 'Within /app, only the data directory may be writable by the server user.'
}
start_service() {
local index=$1 volume_name=$2 binding state deadline
create_container "$index" --publish 127.0.0.1::8090 \
--mount "type=volume,src=$volume_name,dst=/app/data" "$image"
docker_local start "$cid" > "$run_results/start-$index.log"
binding=$(docker_local port "$cid" 8090/tcp)
[[ "$binding" =~ ^127\.0\.0\.1:([0-9]+)$ ]] || { printf 'Unexpected port binding.\n' >&2; return 1; }
url="http://127.0.0.1:${BASH_REMATCH[1]}"
printf 'index=%s binding=%s\n' "$index" "$binding" >> "$run_results/ownership.log"
# Ready means Docker's own in-image probe reports healthy and the host sees the same body.
deadline=$((SECONDS + 60))
while ((SECONDS < deadline)); do
state=$(docker_local inspect --format '{{.State.Running}} {{.State.Health.Status}}' "$cid")
[[ "$state" == 'true '* ]] || { printf 'Container exited before readiness.\n' >&2; return 1; }
[[ "$state" != 'true unhealthy' ]] || { printf 'Container reported unhealthy.\n' >&2; return 1; }
if [[ "$state" == 'true healthy' ]] && curl --noproxy '*' --fail --silent --show-error \
--connect-timeout 1 --max-time 2 "$url/healthz" > "$scratch/health.json" \
2>> "$run_results/readiness-$index.log"; then
if [[ $(< "$scratch/health.json") == '{"status":"ok"}' ]]; then
check_runtime "$index"
return 0
fi
fi
sleep 1
done
printf 'Container readiness timed out.\n' >&2; return 1
}
retire_service() {
local index=$1 retired
read -r retired < "${cidfiles[$index]}" || [[ -n "$retired" ]]
docker_local logs "$retired" > "$run_results/container-$index.log" 2>&1
docker_local inspect --format 'id={{.Id}} running={{.State.Running}} exit={{.State.ExitCode}}' \
"$retired" > "$run_results/container-$index-state.log"
docker_local stop --time 10 "$retired" > "$run_results/stop-$index.log"
docker_local rm "$retired" > "$run_results/remove-$index.log"
}
# An unusable data location must stop the service, never leave a healthy but unusable one.
expect_startup_failure() {
local index=$1 state='' running exit_code health deadline
shift
create_container "$index" --network none "$@" "$image"
docker_local start "$cid" > "$run_results/start-$index.log"
deadline=$((SECONDS + 45))
while ((SECONDS < deadline)); do
state=$(docker_local inspect --format '{{.State.Running}} {{.State.ExitCode}} {{.State.Health.Status}}' "$cid")
[[ "$state" != *' healthy' ]] || fail 'A service with unusable storage reported healthy.'
[[ "$state" == 'false '* ]] && break
sleep 1
done
read -r running exit_code health <<< "$state"
docker_local logs "$cid" > "$run_results/startup-failure-$index.log" 2>&1
printf 'index=%s running=%s exit=%s health=%s\n' "$index" "$running" "$exit_code" "$health" \
>> "$run_results/startup-failures.log"
[[ "$running" == false && "$exit_code" != 0 && "$health" != healthy ]] \
|| fail 'A service with unusable storage did not fail startup.'
[[ $(< "$run_results/startup-failure-$index.log") == *SqliteException* ]] \
|| fail 'Startup failed for a reason other than storage.'
}
run_tool() {
local mode=$1
shift
timeout 100s dotnet "$dll" "$mode" --url "$url" "$@" > "$run_results/$mode-$service.log" 2>&1
}
# A fresh named volume must be writable by the non-root image user.
create_volume 0
service=first
start_service 0 "${volumes[0]}"
run_tool seed --state "$scratch/state.json"
run_tool reject
run_tool limits
retire_service 0
# The replacement gets another Docker-allocated loopback port; only the volume is reused.
service=replacement
start_service 1 "${volumes[0]}"
run_tool verify --state "$scratch/state.json"
retire_service 1
# Back up the whole data directory from the stopped service (SQLite WAL makes a live
# nests.db copy insufficient), then restore into a separate volume and verify it.
create_container 2 --rm --network none --entrypoint tar \
--mount "type=volume,src=${volumes[0]},dst=/app/data,readonly" "$image" -C /app/data -cf - .
docker_local start --attach "$cid" > "$scratch/data-backup.tar" 2> "$run_results/backup.log"
tar -tvf "$scratch/data-backup.tar" > "$run_results/backup-contents.log"
backup_entries=$(tar -tf "$scratch/data-backup.tar")
[[ $'\n'"$backup_entries"$'\n' == *$'\n./nests.db\n'* ]] || fail 'Backup does not contain nests.db.'
expect_startup_failure 5 --mount "type=volume,src=${volumes[0]},dst=/app/data,readonly"
expect_startup_failure 6 --mount type=tmpfs,dst=/app/data,tmpfs-mode=0755
create_volume 1
create_container 3 --rm --interactive --network none --entrypoint tar \
--mount "type=volume,src=${volumes[1]},dst=/app/data" "$image" -C /app/data -xf -
docker_local start --attach --interactive "$cid" < "$scratch/data-backup.tar" > "$run_results/restore.log" 2>&1
service=restored
start_service 4 "${volumes[1]}"
run_tool verify --state "$scratch/state.json"
printf 'PASS: non-root healthy runtime, real-client round trips, rejections, upload limit, persistent recreation, stopped-service backup/restore, and storage startup failures.\n'
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Bounded validity/fulfillment gate; parallel elapsed times are not speed evidence."""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import zipfile
ROOT = Path(__file__).resolve().parents[1]
# Exact demand and sheet count: empty, skipped, and partial jobs must never pass.
EXPECTED = {
"single-triangle": (1, 1),
"paired-wedges": (2, 1),
"repeated-ell-fill": (7, 1),
"mixed-irregular": (6, 1),
"rotation-edge-fit": (1, 1),
"multi-sheet": (3, 3),
}
class GateError(Exception):
pass
def check_fixtures(directory):
files = list(directory.rglob("*.nest"))
names = [path.stem for path in files]
if len(names) != len(EXPECTED) or set(names) != set(EXPECTED):
raise GateError(f"Expected exactly six fixtures {sorted(EXPECTED)}; found {sorted(names)}")
if list(directory.rglob("*.manifest.json")):
raise GateError("Unexpected manifest in synthetic fixture directory")
hashes = {}
for path in sorted(files):
with zipfile.ZipFile(path) as archive:
metadata = json.loads(archive.read("nest.json").decode("utf-8-sig"))
drawings = metadata["drawings"]
demand = sum(drawing["quantity"]["required"] for drawing in drawings)
if demand != EXPECTED[path.stem][0] or not drawings:
raise GateError(f"Unexpected demand in {path.name}: {demand}")
if any(metadata.get(key) for key in ("customer", "madeBy")):
raise GateError(f"Non-synthetic identity metadata in {path.name}")
for drawing in drawings:
if drawing.get("customer") or drawing.get("source", {}).get("path"):
raise GateError(f"Non-synthetic drawing metadata in {path.name}")
if archive.comment or any(entry.comment for entry in archive.infolist()):
raise GateError(f"Unexpected ZIP comments in {path.name}")
hashes[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
return hashes
def check_csv(path):
seen = set()
outcomes = []
with path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
required = {"Job", "Engine", "Valid", "Crashed", "FullyPlaced", "PartsPlaced",
"PartsRequested", "PlatesUsed", "Notes"}
if not required.issubset(reader.fieldnames or []):
raise GateError("Missing benchmark CSV columns")
for row in reader:
if row["Engine"] == "Baseline":
continue
if row["Engine"] != "Irregular":
raise GateError(f"Unexpected engine: {row['Engine']}")
name = row["Job"]
if name not in EXPECTED or name in seen:
raise GateError(f"Unknown or duplicate fixture result: {name}")
seen.add(name)
demand, sheets = EXPECTED[name]
if (row["Valid"] != "True" or row["Crashed"] != "False"
or row["FullyPlaced"] != "True" or row["Notes"].strip()):
raise GateError(f"Invalid, crashed, timed-out or incomplete result: {name}: {row}")
if (int(row["PartsRequested"]) != demand or int(row["PartsPlaced"]) != demand
or int(row["PlatesUsed"]) != sheets):
raise GateError(f"Unexpected fulfillment or sheet count: {name}: {row}")
outcomes.append(row)
if seen != set(EXPECTED):
raise GateError(f"Missing fixture results: {sorted(set(EXPECTED) - seen)}")
return outcomes
def terminate_tree(process):
if os.name == "nt":
if process.poll() is None:
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
timeout=10)
else:
# Also remove any descendants left after their parent exits. The session is ours alone.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=10)
def run_process(command, deadline, log):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise GateError("Whole-check watchdog expired")
print("Running: " + " ".join(map(str, command)), flush=True)
with log.open("w", encoding="utf-8") as output:
process = subprocess.Popen(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
start_new_session=os.name != "nt",
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
try:
code = process.wait(timeout=remaining)
if code:
raise GateError(f"Subprocess exited {code}; see {log}")
except subprocess.TimeoutExpired as error:
raise GateError(f"Whole-check watchdog expired; see {log}") from error
finally:
terminate_tree(process)
def bounded_int(low, high):
def parse(value):
number = int(value)
if not low <= number <= high:
raise argparse.ArgumentTypeError(f"must be between {low} and {high}")
return number
return parse
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--parallel", type=bounded_int(1, 4), default=2)
parser.add_argument("--timeout", type=bounded_int(1, 300), default=300,
help="whole build + solve budget, seconds (maximum 300)")
parser.add_argument("--fixtures", type=Path, default=ROOT / "test-data/synthetic-nests")
parser.add_argument("--output", type=Path, help="retain logs/CSV/provenance in this directory")
parser.add_argument("--check-csv", type=Path, help="check an existing CSV without solving")
args = parser.parse_args(argv)
try:
if args.check_csv:
outcomes = check_csv(args.check_csv)
else:
deadline = time.monotonic() + args.timeout
hashes = check_fixtures(args.fixtures)
output = args.output or Path(tempfile.mkdtemp(prefix="opennest-synthetic-"))
output = output.resolve()
output.mkdir(parents=True, exist_ok=True)
(output / "accepted.json").unlink(missing_ok=True)
print(f"Evidence directory: {output}", flush=True)
project = ROOT / "OpenNest.Benchmark/OpenNest.Benchmark.csproj"
run_process(["dotnet", "build", str(project), "-c", "Release",
"--disable-build-servers"], deadline, output / "build.log")
assembly = ROOT / "OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll"
command = ["dotnet", str(assembly), str(args.fixtures.resolve()),
"--engines", "Irregular", "--parallel", str(args.parallel),
"--progress", "--csv", str(output / "results.csv")]
run_process(command, deadline, output / "benchmark.log")
outcomes = check_csv(output / "results.csv")
if check_fixtures(args.fixtures) != hashes:
raise GateError("Fixture inputs changed during the check")
(output / "accepted.json").write_text(json.dumps({
"fixtures": hashes, "parallel": args.parallel,
"benchmark_command": list(map(str, command)), "outcomes": outcomes,
"benchmark_assembly_sha256": hashlib.sha256(assembly.read_bytes()).hexdigest(),
"checker_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"timing": "diagnostic only; not speed evidence",
}, indent=2) + "\n", encoding="utf-8")
for row in sorted(outcomes, key=lambda row: row["Job"]):
print(f"PASS {row['Job']}: {row['PartsPlaced']}/{row['PartsRequested']} parts, "
f"{row['PlatesUsed']} sheet(s)")
print("PASS: exactly six Irregular results, all valid and fully placed")
return 0
except KeyboardInterrupt:
print("FAIL: cancelled; owned subprocess tree terminated", file=sys.stderr)
return 130
except (GateError, OSError, ValueError, KeyError, zipfile.BadZipFile,
subprocess.SubprocessError) as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
def cancel(_signum, _frame):
raise KeyboardInterrupt()
if __name__ == "__main__":
# Treat CI/terminal termination the same as Ctrl-C; run_process's finally owns cleanup.
signal.signal(signal.SIGTERM, cancel)
sys.exit(main())
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""Fail-closed, read-only source/GHCR checks. This tool never tags or pushes.
Credentials stay in memory; outputs contain only validated provenance/digests.
The workflow alone owns Docker login and the two explicit push commands.
"""
import argparse
import base64
import gzip
import hashlib
import io
import json
import os
import pathlib
import re
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.parse
import urllib.request
REPO = "ajisaacs/OpenNest"
SOURCE = "https://github.com/" + REPO
PACKAGE = "opennest-server"
REGISTRY_NAME = "ajisaacs/" + PACKAGE
IMAGE = "ghcr.io/" + REGISTRY_NAME
REPO_API = "https://api.github.com/repos/" + REPO
PACKAGE_API = "https://api.github.com/users/ajisaacs/packages/container/" + PACKAGE
MANIFEST_TYPES = ("application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json")
# Any stored image representation is a collision; published readback stays strict.
REGISTRY_MANIFEST_TYPES = MANIFEST_TYPES + ("application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json")
class ReleaseError(Exception):
pass
def require(condition, message):
if not condition:
raise ReleaseError(message)
def version(tag):
require(isinstance(tag, str) and re.fullmatch(
r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", tag), "invalid release tag")
return tag[1:]
def commit(value):
require(isinstance(value, str) and re.fullmatch(r"[0-9a-f]{40}", value), "invalid full commit SHA")
return value
def sha256(value):
require(isinstance(value, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", value), "invalid SHA-256 digest")
return value
def json_body(body):
def unique(pairs):
result = {}
for key, value in pairs:
require(key not in result, "duplicate JSON key")
result[key] = value
return result
try:
return json.loads(body, object_pairs_hook=unique)
except (ValueError, UnicodeError, TypeError):
raise ReleaseError("malformed JSON response") from None
def command(*args):
try:
result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=300)
except (OSError, subprocess.TimeoutExpired):
raise ReleaseError("local command unavailable or timed out") from None
require(result.returncode == 0, "local command failed: " + args[0])
return result.stdout.strip()
def git(*args):
return command("git", *args)
def resolve_source(repo, event, ref, tag, event_sha=None):
version(tag) # Validate before constructing a ref or stripping v.
require(repo == REPO, "publication requires the primary repository")
require((event == "workflow_dispatch" and ref == "refs/heads/master") or
(event == "release" and ref == "refs/tags/" + tag), "unapproved publication event/ref")
expected = None
if event == "release":
expected = commit(event_sha if event_sha is not None else os.environ.get("GITHUB_SHA", ""))
sha = commit(git("rev-parse", "--verify", "refs/tags/" + tag + "^{commit}"))
require(expected is None or sha == expected, "release tag differs from event commit")
git("merge-base", "--is-ancestor", sha, "refs/remotes/origin/master")
return sha
def registry_path(suffix):
return "https://ghcr.io/v2/" + REGISTRY_NAME + "/" + suffix
def absent(status, headers, body):
if status == 200:
return False
require(status == 404, "registry lookup failed; not proven absent")
data = json_body(body)
require(isinstance(data, dict) and isinstance(data.get("errors"), list) and data["errors"],
"malformed registry absence response")
require(all(isinstance(error, dict) and error.get("code") in ("MANIFEST_UNKNOWN", "NAME_UNKNOWN")
for error in data["errors"]), "registry denial/error is not absence")
return True
def package_policy(status, headers, body):
data = json_body(body)
require(isinstance(data, dict), "malformed package metadata")
require(status == 200, "package metadata inaccessible; privacy unknown")
require(data.get("name") == PACKAGE and data.get("package_type") == "container" and
data.get("visibility") == "private" and isinstance(data.get("repository"), dict) and
data["repository"].get("full_name") == REPO, "package privacy/association mismatch")
return False
def registry_token(status, body):
# Opaque token issuance does not prove the granted scope: GHCR may reduce it.
# Never use token success or registry absence to override package metadata.
require(status == 200, "registry authentication/scope request failed")
data = json_body(body)
require(isinstance(data, dict) and isinstance(data.get("token"), str) and
bool(data["token"]) and not any(c.isspace() for c in data["token"]),
"malformed token response")
if "scope" in data:
require(data["scope"] == "repository:" + REGISTRY_NAME + ":pull,push", "reduced registry scope")
return data["token"]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class Client:
def __init__(self):
self.github_token = os.environ.get("GITHUB_TOKEN", "")
require(bool(self.github_token), "GITHUB_TOKEN required")
require(os.environ.get("GITHUB_REPOSITORY") == REPO, "unexpected authenticated repository")
self.opener = urllib.request.build_opener(NoRedirect())
credentials = base64.b64encode((os.environ.get("GITHUB_ACTOR", "") + ":" +
self.github_token).encode()).decode()
url = "https://ghcr.io/token?" + urllib.parse.urlencode({
"service": "ghcr.io", "scope": "repository:" + REGISTRY_NAME + ":pull,push"})
status, _, body = self.request(url, {"Authorization": "Basic " + credentials})
self.registry_token = registry_token(status, body)
def request(self, url, headers):
try:
with self.opener.open(urllib.request.Request(url, headers=headers), timeout=60) as result:
return result.status, {k.lower(): v for k, v in result.headers.items()}, result.read()
except urllib.error.HTTPError as error:
return error.code, {k.lower(): v for k, v in error.headers.items()}, error.read()
except (OSError, ValueError):
raise ReleaseError("network/transport failure (not absence)") from None
def get(self, path):
if path.startswith("https://api.github.com/"):
return self.request(path, {"Authorization": "Bearer " + self.github_token,
"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"})
require(path.startswith(registry_path("")), "unexpected registry endpoint")
result = self.request(path, {"Authorization": "Bearer " + self.registry_token,
"Accept": ", ".join(REGISTRY_MANIFEST_TYPES)})
if "/blobs/" in path and result[0] in (302, 307):
location = result[1].get("location", "")
parsed = urllib.parse.urlparse(location)
require(parsed.scheme == "https" and parsed.hostname == "pkg-containers.githubusercontent.com",
"unexpected blob redirect")
# Never forward credentials to redirected storage or record signed URLs.
return self.request(location, {})
return result
def preflight(client, release_version, sha):
status, _, body = client.get(REPO_API)
repo = json_body(body)
require(status == 200 and isinstance(repo, dict) and repo.get("full_name") == REPO and
repo.get("default_branch") == "master", "repository/default branch authorization unproven")
package_policy(*client.get(PACKAGE_API))
tags_response = client.get(registry_path("tags/list"))
require(tags_response[0] == 200, "existing package registry read access unproven")
data = json_body(tags_response[2])
require(isinstance(data, dict) and data.get("name") == REGISTRY_NAME and
(data.get("tags") is None or (isinstance(data.get("tags"), list) and
all(isinstance(tag, str) for tag in data["tags"]))), "malformed registry tag list")
require("tags" in data and "link" not in tags_response[1], "incomplete registry tag list")
reserved_tags = (release_version, "sha-" + sha)
require(not any(tag in (data["tags"] or []) for tag in reserved_tags), "refusing existing immutable tag")
for tag in reserved_tags:
require(absent(*client.get(registry_path("manifests/" + tag))), "refusing existing immutable tag")
return {"package": IMAGE, "visibility": "private"}
def content_digest(body):
return "sha256:" + hashlib.sha256(body).hexdigest()
def stream_digest(stream):
digest = hashlib.sha256()
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return "sha256:" + digest.hexdigest()
def layer_diff_id(stream, media_type):
try:
if media_type in ("application/vnd.oci.image.layer.v1.tar+gzip",
"application/vnd.docker.image.rootfs.diff.tar.gzip"):
with gzip.GzipFile(fileobj=stream) as decoded:
return stream_digest(decoded)
require(media_type == "application/vnd.oci.image.layer.v1.tar", "unsupported layer encoding")
return stream_digest(stream)
except (OSError, EOFError):
raise ReleaseError("invalid layer compression") from None
def image_manifest(body):
data = json_body(body)
require(isinstance(data, dict) and data.get("schemaVersion") == 2 and
data.get("mediaType") in MANIFEST_TYPES and isinstance(data.get("config"), dict) and
isinstance(data.get("layers"), list), "unexpected manifest (single-platform required)")
return data
def rootfs(config):
data = config.get("rootfs")
require(isinstance(data, dict) and data.get("type") == "layers" and
isinstance(data.get("diff_ids"), list) and data["diff_ids"], "missing config rootfs chain")
return [sha256(value) for value in data["diff_ids"]]
def archive_identity(path, local):
"""Bind verified saved bytes to the inspected immutable store identity.
Both stores may save an OCI envelope. Classic Id hashes raw config;
containerd Id/Descriptor identify the manifest, whose verified descriptors
bind config/layers. Inspect Config is reconstructed, not the saved JSON.
No archive extraction, and all temporary image bytes are removed by caller.
"""
try:
with tarfile.open(path) as archive:
def member(name):
entry = archive.getmember(name)
require(entry.isfile(), "archive member is not a regular file")
stream = archive.extractfile(entry)
if stream is None:
raise ReleaseError("missing archive file")
return stream
def blob(descriptor):
require(isinstance(descriptor, dict), "invalid blob descriptor")
digest = sha256(descriptor.get("digest"))
stream = member("blobs/sha256/" + digest[7:])
require(type(descriptor.get("size")) is int and descriptor["size"] == archive.getmember("blobs/sha256/" + digest[7:]).size,
"archive blob size mismatch")
require(stream_digest(stream) == digest, "archive blob digest mismatch")
stream.close()
return member("blobs/sha256/" + digest[7:])
names = archive.getnames()
require(len(names) == len(set(names)), "duplicate archive members")
exported_descriptor = None
if "index.json" in names:
index = json_body(member("index.json").read())
require(isinstance(index, dict) and index.get("schemaVersion") == 2 and
isinstance(index.get("manifests"), list) and len(index["manifests"]) == 1,
"ambiguous image archive")
descriptor = index["manifests"][0]
require(isinstance(descriptor, dict) and descriptor.get("mediaType") in MANIFEST_TYPES,
"unexpected archive image index/attestation")
manifest = image_manifest(blob(descriptor).read())
require(manifest["mediaType"] == descriptor["mediaType"], "archive manifest media type mismatch")
exported_descriptor = descriptor
config_body = blob(manifest["config"]).read()
layers = manifest["layers"]
diffs = [layer_diff_id(blob(layer), layer.get("mediaType")) for layer in layers]
else:
entries = json_body(member("manifest.json").read())
require(isinstance(entries, list) and len(entries) == 1 and isinstance(entries[0], dict),
"ambiguous classic image archive")
config_body = member(entries[0]["Config"]).read()
diffs = [stream_digest(member(name)) for name in entries[0]["Layers"]]
config_digest = content_digest(config_body)
inspected_descriptor = local.get("Descriptor")
if inspected_descriptor is None:
require(config_digest == sha256(local.get("Id")),
"saved config does not bind inspected immutable image")
else:
require(isinstance(inspected_descriptor, dict) and isinstance(exported_descriptor, dict) and
type(inspected_descriptor.get("size")) is int and
all(inspected_descriptor.get(key) == exported_descriptor.get(key)
for key in ("digest", "size", "mediaType")) and
exported_descriptor["digest"] == sha256(local.get("Id")),
"saved manifest does not bind inspected immutable image")
config = json_body(config_body)
require(isinstance(config, dict) and config.get("os") == local.get("Os") and
config.get("architecture") == local.get("Architecture") and isinstance(config.get("config"), dict),
"saved config platform mismatch")
require(diffs == rootfs(config) == local.get("RootFS", {}).get("Layers"), "saved layer/rootfs chain mismatch")
return {"config_digest": config_digest, "rootfs_diff_ids": diffs, "image_config": config}
except (OSError, tarfile.TarError, KeyError, TypeError, AttributeError):
raise ReleaseError("invalid image archive") from None
def local_identity(image):
local = inspect(image)
# Use the inspected immutable store ID, not a potentially retargeted tag.
with tempfile.TemporaryDirectory(prefix="opennest-image-identity-") as directory:
path = pathlib.Path(directory) / "image.tar"
command("docker", "image", "save", "-o", str(path), sha256(local.get("Id")))
local.update(archive_identity(path, local))
require(inspect(image)["Id"] == local["Id"], "local image changed while inspecting")
return local
def check_local(local, release_version, sha):
require(isinstance(local, dict), "invalid local image inspect")
image_id = sha256(local.get("Id"))
require(local.get("Os") == "linux" and local.get("Architecture") == "amd64", "wrong image platform")
config = local.get("Config")
require(isinstance(config, dict) and isinstance(config.get("Labels"), dict), "missing image config/labels")
labels = config["Labels"]
for key, expected in (("source", SOURCE), ("version", release_version), ("revision", sha)):
require(labels.get("org.opencontainers.image." + key) == expected, "OCI label mismatch: " + key)
base = labels.get("org.opencontainers.image.base.name", "")
require(isinstance(base, str) and re.fullmatch(
r"mcr\.microsoft\.com/dotnet/aspnet@sha256:[0-9a-f]{64}", base), "runtime base not digest pinned")
config_digest = sha256(local.get("config_digest"))
diffs = local.get("rootfs_diff_ids")
require(isinstance(diffs, list) and diffs and
diffs == local.get("RootFS", {}).get("Layers"), "local rootfs identity mismatch")
for value in diffs:
sha256(value)
return {"image_id": image_id, "config_digest": config_digest, "rootfs_diff_ids": diffs,
"platform": "linux/amd64", "version": release_version,
"source_sha": sha, "runtime_image": base, "source": SOURCE}
def readback(client, release_version, sha, local):
result = check_local(local, release_version, sha)
manifests = []
digests = []
for tag in (release_version, "sha-" + sha):
status, headers, body = client.get(registry_path("manifests/" + tag))
require(status == 200, "published tag readback failed")
remote_digest = sha256(headers.get("docker-content-digest"))
require(remote_digest == "sha256:" + hashlib.sha256(body).hexdigest(), "manifest content digest mismatch")
manifest = image_manifest(body)
manifests.append(manifest)
digests.append(remote_digest)
require(digests[0] == digests[1], "published tags have different digests")
descriptor = manifests[0]["config"]
config_digest = sha256(descriptor.get("digest"))
require(config_digest == result["config_digest"], "registry config differs from smoked local config")
status, _, body = client.get(registry_path("blobs/" + config_digest))
require(status == 200 and type(descriptor.get("size")) is int and descriptor["size"] == len(body) and
config_digest == "sha256:" + hashlib.sha256(body).hexdigest(), "config blob readback mismatch")
remote = json_body(body)
require(isinstance(remote, dict) and remote.get("os") == "linux" and remote.get("architecture") == "amd64" and
remote == local.get("image_config"), "remote platform/image config mismatch")
diffs = rootfs(remote)
require(diffs == result["rootfs_diff_ids"] and len(manifests[0]["layers"]) == len(diffs),
"remote rootfs chain differs from smoked image")
for layer, expected in zip(manifests[0]["layers"], diffs):
require(isinstance(layer, dict), "invalid remote layer descriptor")
digest = sha256(layer.get("digest"))
status, _, body = client.get(registry_path("blobs/" + digest))
require(status == 200 and type(layer.get("size")) is int and layer["size"] == len(body) and
digest == content_digest(body), "layer blob readback mismatch")
require(layer_diff_id(io.BytesIO(body), layer.get("mediaType")) == expected,
"remote layer does not match smoked rootfs")
result.update(manifest_digest=digests[0], config_digest=config_digest, package=IMAGE)
return result
def inspect(image):
result = json_body(command("docker", "image", "inspect", image))
require(isinstance(result, list) and len(result) == 1, "ambiguous local image")
return result[0]
def outputs(values):
path = os.environ.get("GITHUB_OUTPUT")
if path:
with open(path, "a", encoding="utf-8") as file:
for key, value in values.items():
require(isinstance(value, str) and "\n" not in value and "\r" not in value, "unsafe workflow output")
file.write(key + "=" + value + "\n")
def save(name, data):
directory = pathlib.Path(os.environ.get("METADATA_DIR", ".hermes/server-image-metadata"))
directory.mkdir(parents=True, exist_ok=True)
(directory / name).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("source", "bases", "local", "preflight", "readback", "pulled"))
args = parser.parse_args()
if args.operation == "source":
tag = os.environ.get("TAG", "")
sha = resolve_source(os.environ.get("GITHUB_REPOSITORY"), os.environ.get("GITHUB_EVENT_NAME"),
os.environ.get("GITHUB_REF"), tag, os.environ.get("GITHUB_SHA"))
outputs({"source_sha": sha, "version": version(tag)})
save("source.json", {"source_sha": sha, "version": version(tag), "tag": tag})
return
if args.operation == "bases":
bases = {}
for key, kind in (("sdk_image", "sdk"), ("runtime_image", "aspnet")):
name = "mcr.microsoft.com/dotnet/" + kind
command("docker", "pull", "--platform", "linux/amd64", name + ":8.0")
data = inspect(name + ":8.0")
candidates = [value for value in data.get("RepoDigests", []) if value.startswith(name + "@")]
require(len(candidates) == 1, "base digest not uniquely resolved")
sha256(candidates[0].split("@", 1)[1])
bases[key] = candidates[0]
outputs(bases)
save("bases.json", bases)
return
release_version = version("v" + os.environ.get("VERSION", ""))
sha = commit(os.environ.get("SOURCE_SHA", ""))
require(git("rev-parse", "HEAD") == sha, "checkout is not the exact tested source")
if args.operation == "preflight":
save("preflight.json", preflight(Client(), release_version, sha))
return
local = local_identity(os.environ.get("LOCAL_IMAGE", ""))
if args.operation == "readback":
client = Client()
package_policy(*client.get(PACKAGE_API))
directory = pathlib.Path(os.environ.get("METADATA_DIR", ".hermes/server-image-metadata"))
try:
previous = json_body((directory / "local.json").read_bytes())
except OSError:
raise ReleaseError("pre-smoke identity unavailable") from None
require(previous == check_local(local, release_version, sha), "image differs from pre-smoke identity")
result = readback(client, release_version, sha, local)
outputs({"manifest_digest": result["manifest_digest"], "config_digest": result["config_digest"]})
save("registry.json", result)
else:
result = check_local(local, release_version, sha)
if args.operation == "pulled":
require(result["config_digest"] == sha256(os.environ.get("CONFIG_DIGEST", "")), "pulled config digest mismatch")
expected = sha256(os.environ.get("MANIFEST_DIGEST", ""))
require(IMAGE + "@" + expected in local.get("RepoDigests", []), "pulled registry digest mismatch")
if args.operation == "local":
outputs({"image_id": result["image_id"]})
save("pulled.json" if args.operation == "pulled" else "local.json", result)
if __name__ == "__main__":
try:
main()
except ReleaseError as error:
print("STOP: " + str(error), file=sys.stderr)
sys.exit(1)
+171
View File
@@ -0,0 +1,171 @@
"""Unit data below is intentionally synthetic CSV, not recorded benchmark evidence."""
import csv
import importlib.util
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import unittest
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("synthetic_gate", Path(__file__).with_name("check-synthetic-nests.py"))
assert spec is not None and spec.loader is not None
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class SyntheticGateTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="opennest-gate-test-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.csv = self.root / "results.csv"
self.rows = [dict(Job=name, Engine="Irregular", Valid="True", Crashed="False",
FullyPlaced="True", PartsPlaced=str(demand), PartsRequested=str(demand),
PlatesUsed=str(sheets), Notes="")
for name, (demand, sheets) in gate.EXPECTED.items()]
def write_csv(self, rows):
with self.csv.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=list(self.rows[0]))
writer.writeheader()
writer.writerows(rows)
def test_exact_six_and_baseline_filter(self):
self.write_csv(self.rows + [dict(self.rows[0], Engine="Baseline", Valid="False")])
self.assertEqual(6, len(gate.check_csv(self.csv)))
def test_fail_closed_result_cases(self):
cases = {
"invalid": dict(Valid="False"),
"crashed": dict(Crashed="True", Notes="exception"),
"timeout": dict(Valid="False", Notes="Timed out after 5 minutes"),
"incomplete": dict(FullyPlaced="False", PartsPlaced="0"),
"empty": dict(PartsPlaced="0", PartsRequested="0", PlatesUsed="0"),
"unknown": dict(Job="unknown"),
"engine": dict(Engine="Unknown"),
"demand": dict(PartsRequested="99"),
"sheets": dict(PlatesUsed="0"),
"notes": dict(Notes="validation warning"),
}
for label, change in cases.items():
with self.subTest(label=label):
self.write_csv([dict(self.rows[0], **change)] + self.rows[1:])
with self.assertRaises(gate.GateError):
gate.check_csv(self.csv)
for rows in ([], self.rows[:-1], self.rows + [self.rows[0]]):
self.write_csv(rows)
with self.assertRaises(gate.GateError):
gate.check_csv(self.csv)
def test_malformed_csv_returns_nonzero(self):
self.write_csv([dict(self.rows[0], PartsPlaced="not-a-number")] + self.rows[1:])
self.assertEqual(1, gate.main(["--check-csv", str(self.csv)]))
self.csv.write_text("Job,Engine\n", encoding="utf-8")
self.assertEqual(1, gate.main(["--check-csv", str(self.csv)]))
def test_missing_fixture_actual_entrypoint(self):
self.assertEqual(1, gate.main(["--fixtures", str(self.root)]))
def test_committed_fixtures_are_complete_and_synthetic(self):
self.assertEqual(6, len(gate.check_fixtures(gate.ROOT / "test-data/synthetic-nests")))
def test_concurrency_and_watchdog_are_bounded(self):
for args in (["--parallel", "0"], ["--parallel", "5"], ["--timeout", "301"]):
with self.assertRaises(SystemExit) as error:
gate.main(args)
self.assertEqual(2, error.exception.code)
def test_nonzero_child_fails(self):
script = self.root / "exit.py"
script.write_text("raise SystemExit(7)\n", encoding="utf-8")
with self.assertRaisesRegex(gate.GateError, "exited 7"):
gate.run_process([sys.executable, str(script)], time.monotonic() + 10,
self.root / "exit.log")
@unittest.skipIf(os.name == "nt", "POSIX SIGTERM probe; Windows requires runtime acceptance")
def test_cancellation_kills_ready_owned_tree(self):
parent = self.root / "cancel-parent.py"
pidfile = self.root / "cancel-pids.txt"
ready = self.root / "cancel-ready"
parent.write_text(
"import os, subprocess, sys, time\n"
"from pathlib import Path\n"
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
f"Path({str(pidfile)!r}).write_text(str(os.getpid()) + ' ' + str(child.pid))\n"
f"Path({str(ready)!r}).touch()\n"
"time.sleep(60)\n", encoding="utf-8")
wrapper = self.root / "cancel-wrapper.py"
wrapper.write_text(
"import importlib.util, signal, sys, time\n"
"from pathlib import Path\n"
"sys.dont_write_bytecode = True\n"
f"spec = importlib.util.spec_from_file_location('gate', {str(Path(__file__).with_name('check-synthetic-nests.py'))!r})\n"
"gate = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(gate)\n"
"signal.signal(signal.SIGTERM, gate.cancel)\n"
"try:\n"
f" gate.run_process([sys.executable, {str(parent)!r}], time.monotonic() + 15, "
f"Path({str(self.root / 'cancel.log')!r}))\n"
"except KeyboardInterrupt:\n sys.exit(130)\n", encoding="utf-8")
process = subprocess.Popen([sys.executable, str(wrapper)], start_new_session=True)
try:
end = time.monotonic() + 10
while not ready.exists() and time.monotonic() < end:
time.sleep(0.01)
self.assertTrue(ready.exists(), "cancellation tree never became ready")
pids = [int(value) for value in pidfile.read_text().split()]
process.send_signal(signal.SIGTERM)
self.assertEqual(130, process.wait(timeout=10))
self.assert_quiescent(pids)
finally:
gate.terminate_tree(process)
def assert_quiescent(self, pids):
for pid in pids:
end = time.monotonic() + 5
while True:
status = Path(f"/proc/{pid}/stat")
if status.exists() and status.read_text().split()[2] == "Z":
break # Linux init may not yet have reaped a killed orphan.
try:
os.kill(pid, 0)
except ProcessLookupError:
break
self.assertLess(time.monotonic(), end, f"owned process {pid} remains alive")
time.sleep(0.01)
@unittest.skipIf(os.name == "nt", "POSIX session-tree probe; Windows uses taskkill /T /F")
def test_watchdog_kills_ready_parent_and_grandchild(self):
# A real owned process tree, synchronized before the watchdog. No benchmark results mocked.
child = self.root / "child.py"
child.write_text("import time\ntime.sleep(60)\n", encoding="utf-8")
parent = self.root / "parent.py"
pidfile = self.root / "pids.txt"
ready = self.root / "ready"
parent.write_text(
"import os, subprocess, sys, time\n"
"from pathlib import Path\n"
f"child = subprocess.Popen([sys.executable, {str(child)!r}])\n"
f"Path({str(pidfile)!r}).write_text(str(os.getpid()) + ' ' + str(child.pid))\n"
f"Path({str(ready)!r}).touch()\n"
"time.sleep(60)\n", encoding="utf-8")
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(gate.run_process, [sys.executable, str(parent)],
time.monotonic() + 5, self.root / "timeout.log")
end = time.monotonic() + 4
while not ready.exists() and time.monotonic() < end:
time.sleep(0.01)
self.assertTrue(ready.exists(), "child tree never became ready")
pids = [int(value) for value in pidfile.read_text().split()]
with self.assertRaisesRegex(gate.GateError, "watchdog expired"):
future.result(timeout=10)
self.assert_quiescent(pids)
if __name__ == "__main__":
unittest.main()
+283
View File
@@ -0,0 +1,283 @@
"""Unit fault injection: real Bash cleanup functions, explicitly virtual Docker.
No daemon, .NET build, HTTP endpoint, or real Docker resources are used here.
"""
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
import unittest
TOKEN = "cleanup-unit-owner"
FIRST_ID = "a" * 64
CID = "b" * 64
FIRST_NAME = "cleanup-unit.first"
NAME = "cleanup-unit.replacement"
VOLUME = "cleanup-unit-data"
EXTRA_ID = "c" * 64
EXTRA_NAME = "cleanup-unit.restored"
EXTRA_VOLUME = "cleanup-unit-restore"
def mock_docker(state_path, args):
"""Model only the Docker transport calls needed by the cleanup contract."""
path = Path(state_path)
state = json.loads(path.read_text())
state["calls"].append(args)
path.write_text(json.dumps(state))
kind = "volume" if args[0] == "volume" else "container"
command = args[1] if kind == "volume" else args[0]
resources = state["volumes"] if kind == "volume" else state["containers"]
def fail(message, status):
print(message, file=sys.stderr)
return status
if command == "inspect":
status = state.get("lookup_status", 0) if kind == state.get("fault_kind") else 0
if status:
return fail(f"Injected Docker {kind} lookup failure status={status}", status)
target = args[-1]
resource = next(
(key for key, value in resources.items() if target in (key, value.get("name"))),
None,
)
if resource is None:
return fail(f"Error: No such {kind}: {target}", 1)
template = args[args.index("--format") + 1]
owner = resources[resource]["owner"]
if "Labels" in template:
identity = ".Name" if kind == "volume" else ".Id"
print(f"{resource} {owner}" if identity in template else owner)
elif template == "{{.Id}}":
print(resource)
else:
print(f"id={resource} running=true exit=0")
return 0
if command == "ls" or args[:2] == ["container", "ls"]:
status = state.get("inventory_status", 0)
if state.get("inventory_after_removal") and not state["removals"]:
status = 0
if status:
return fail(f"Injected Docker inventory failure status={status}", status)
filter_value = args[args.index("--filter") + 1]
field, target = filter_value.split("=", 1)
for key, value in resources.items():
if field == "id":
matches = key.startswith(target)
elif kind == "container":
matches = re.search(target, "/" + value["name"]) is not None
else:
matches = re.search(target, key) is not None
if matches:
print(key)
return 0
if command == "rm":
target = args[-1]
state["removals"].append([kind, target])
mode = state.get("remove_mode") if kind == state.get("fault_kind") else None
if mode not in ("error", "no_effect"):
del resources[target]
path.write_text(json.dumps(state))
if mode == "error":
return fail("Injected Docker removal failure", 1)
print(target)
return 0
if command == "logs":
print("Virtual container log; no real daemon contacted")
return 0
return fail(f"Unexpected mock Docker call: {args!r}", 97)
class CleanupTests(unittest.TestCase):
def run_cleanup(self, *, missing_cidfile=False, absent_first=False,
absent_all=False, unowned=False, attempted=True,
original_status=0, extra=False, **faults):
source = Path(__file__).with_name("Test-ServerContainer.sh").read_text()
# Extract the actual candidate functions, never a copied cleanup implementation.
functions = source[source.index("owned_container() {"):source.index("trap cleanup EXIT")]
temp_root = Path(os.environ.get("TMPDIR", str(Path.home() / ".cache")))
temp_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="opennest-cleanup-unit.", dir=temp_root) as temp:
root = Path(temp)
scratch = root / "scratch"
logs = root / "results"
scratch.mkdir()
logs.mkdir()
if not missing_cidfile:
(scratch / "replacement.cid").write_text(CID + "\n")
if absent_first:
(scratch / "first.cid").write_text(FIRST_ID + "\n")
if extra:
(scratch / "restored.cid").write_text(EXTRA_ID + "\n")
owner = "someone-else" if unowned else TOKEN
state_path = root / "virtual-docker.json"
containers = {} if absent_all else {CID: {"name": NAME, "owner": owner}}
volumes = {} if absent_all else {VOLUME: {"owner": owner}}
names = [FIRST_NAME, NAME]
cidfiles = [scratch / "first.cid", scratch / "replacement.cid"]
container_attempted = [absent_first and attempted, attempted]
volume_names = [VOLUME]
if extra:
containers[EXTRA_ID] = {"name": EXTRA_NAME, "owner": owner}
volumes[EXTRA_VOLUME] = {"owner": owner}
names.append(EXTRA_NAME)
cidfiles.append(scratch / "restored.cid")
container_attempted.append(attempted)
volume_names.append(EXTRA_VOLUME)
state = {
"containers": containers,
"volumes": volumes,
"calls": [], "removals": [], **faults,
}
state_path.write_text(json.dumps(state))
q = shlex.quote
def bash_array(values):
return "(" + " ".join(q(str(value)) for value in values) + ")"
def bash_flags(values):
return "(" + " ".join(str(value).lower() for value in values) + ")"
script = "\n".join([
"set -Eeuo pipefail",
f"scratch={q(str(scratch))}",
f"run_results={q(str(logs))}",
"owner_label='com.opennest.server-smoke.owner'",
f"token={q(TOKEN)}",
f"volumes={bash_array(volume_names)}",
f"volume_attempted={bash_flags([attempted] * len(volume_names))}",
f"container_attempted={bash_flags(container_attempted)}",
f"container_names={bash_array(names)}",
f"cidfiles={bash_array(cidfiles)}",
f'docker_local() {{ {q(sys.executable)} {q(str(Path(__file__).resolve()))} '
f'--mock-docker {q(str(state_path))} "$@"; }}',
functions,
"trap cleanup EXIT",
f"exit {original_status}",
])
run = subprocess.run(["bash", "-c", script], text=True, capture_output=True, timeout=15)
self.assertFalse(scratch.exists(), "owned scratch must be removed on every outcome")
self.assertTrue((logs / "outcome.log").is_file(), run.stderr)
return {
"status": run.returncode,
"outcome": (logs / "outcome.log").read_text(),
"log": (logs / "cleanup.log").read_text() if (logs / "cleanup.log").exists() else "",
"state": json.loads(state_path.read_text()),
"stderr": run.stderr,
}
def assert_failed(self, result, status=1):
self.assertEqual(status, result["status"], result)
self.assertEqual(f"exit={status} cleanup_failed=1\n", result["outcome"])
def test_healthy_owned_resources_are_removed(self):
for missing in (False, True):
with self.subTest(missing_cidfile=missing):
result = self.run_cleanup(missing_cidfile=missing)
self.assertEqual(0, result["status"], result)
self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"])
self.assertEqual([["container", CID], ["volume", VOLUME]], result["state"]["removals"])
self.assertFalse(result["state"]["containers"])
self.assertFalse(result["state"]["volumes"])
def test_first_container_already_removed_is_successful_noop(self):
result = self.run_cleanup(absent_first=True)
self.assertEqual(0, result["status"], result)
self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"])
self.assertNotIn(["container", FIRST_ID], result["state"]["removals"])
def test_proven_absence_with_or_without_cidfile_is_success(self):
for missing in (False, True):
with self.subTest(missing_cidfile=missing):
result = self.run_cleanup(absent_all=True, missing_cidfile=missing)
self.assertEqual(0, result["status"], result)
self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"])
self.assertFalse(result["state"]["removals"])
def test_lookup_timeout_or_daemon_error_fails_closed(self):
for kind in ("container", "volume"):
for status in (124, 1):
for missing in (False, True):
with self.subTest(kind=kind, status=status, missing_cidfile=missing):
result = self.run_cleanup(fault_kind=kind, lookup_status=status,
missing_cidfile=missing)
self.assert_failed(result)
self.assertIn(f"Injected Docker {kind} lookup failure status={status}", result["log"])
self.assertIn("unknown", result["log"].lower())
self.assertFalse(any(r[0] == kind for r in result["state"]["removals"]))
self.assertTrue(result["state"]["containers" if kind == "container" else "volumes"])
def test_failed_inventory_cannot_prove_absence(self):
for status in (124, 1):
for missing in (False, True):
with self.subTest(status=status, missing_cidfile=missing):
result = self.run_cleanup(absent_all=True, missing_cidfile=missing,
inventory_status=status)
self.assert_failed(result)
self.assertIn(f"Injected Docker inventory failure status={status}", result["log"])
self.assertIn("unknown", result["log"].lower())
self.assertFalse(result["state"]["removals"])
def test_mismatched_owner_is_never_removed_or_claimed_complete(self):
for missing in (False, True):
with self.subTest(missing_cidfile=missing):
result = self.run_cleanup(unowned=True, missing_cidfile=missing)
self.assert_failed(result)
self.assertFalse(result["state"]["removals"])
self.assertIn("owner", result["log"].lower())
def test_preexisting_unattempted_resources_are_untouched(self):
result = self.run_cleanup(unowned=True, attempted=False)
self.assertEqual(0, result["status"], result)
self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"])
self.assertFalse(result["state"]["calls"])
self.assertTrue(result["state"]["containers"])
self.assertTrue(result["state"]["volumes"])
def test_removal_failure_or_false_success_fails_completion(self):
for kind in ("container", "volume"):
for mode in ("error", "no_effect"):
with self.subTest(kind=kind, mode=mode):
result = self.run_cleanup(fault_kind=kind, remove_mode=mode)
self.assert_failed(result)
self.assertTrue(result["state"]["containers" if kind == "container" else "volumes"])
self.assertTrue(result["log"])
def test_inconclusive_post_removal_readback_fails_completion(self):
result = self.run_cleanup(inventory_status=124, inventory_after_removal=True)
self.assert_failed(result)
self.assertIn("Injected Docker inventory failure status=124", result["log"])
def test_original_smoke_failure_is_preserved(self):
result = self.run_cleanup(original_status=23, fault_kind="container", lookup_status=124)
self.assert_failed(result, status=23)
self.assertIn("Injected Docker container lookup failure status=124", result["log"])
def test_every_attempted_container_and_volume_is_removed(self):
result = self.run_cleanup(extra=True)
self.assertEqual(0, result["status"], result)
self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"])
self.assertEqual([["container", CID], ["container", EXTRA_ID], ["volume", VOLUME],
["volume", EXTRA_VOLUME]], result["state"]["removals"])
self.assertFalse(result["state"]["containers"])
self.assertFalse(result["state"]["volumes"])
def test_one_failed_volume_does_not_skip_or_hide_the_others(self):
result = self.run_cleanup(extra=True, fault_kind="volume", remove_mode="error")
self.assert_failed(result)
self.assertEqual([["container", CID], ["container", EXTRA_ID], ["volume", VOLUME],
["volume", EXTRA_VOLUME]], result["state"]["removals"])
self.assertFalse(result["state"]["containers"])
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--mock-docker":
sys.exit(mock_docker(sys.argv[2], sys.argv[3:]))
unittest.main()
+757
View File
@@ -0,0 +1,757 @@
"""Behavioral gates for the read-only server image release helper."""
import copy
import gzip
import hashlib
import io
import os
import tarfile
import importlib.util
import json
import pathlib
import subprocess
import tempfile
import unittest
from unittest import mock
SPEC = importlib.util.spec_from_file_location(
"release", pathlib.Path(__file__).with_name("server_image_release.py"))
assert SPEC is not None and SPEC.loader is not None
release = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release)
SHA = "a" * 40
BASE = "mcr.microsoft.com/dotnet/aspnet@sha256:" + "b" * 64
LAYER = b"deterministic synthetic rootfs layer"
LAYER_GZIP = gzip.compress(LAYER, mtime=0)
def encoded(value):
return json.dumps(value, separators=(",", ":")).encode()
def digest(data):
return "sha256:" + hashlib.sha256(data).hexdigest()
def config():
return {"os": "linux", "architecture": "amd64",
"rootfs": {"type": "layers", "diff_ids": [digest(LAYER)]}, "config": {
"User": "1654", "Entrypoint": ["dotnet", "OpenNest.Server.dll"],
"Labels": {"org.opencontainers.image.source": release.SOURCE,
"org.opencontainers.image.revision": SHA,
"org.opencontainers.image.version": "1.2.3",
"org.opencontainers.image.base.name": BASE}}}
class FakeClient:
def __init__(self, responses):
self.responses = responses
self.calls = []
def get(self, path, **kwargs):
self.calls.append(path)
response = self.responses[path]
if isinstance(response, Exception):
raise response
return response
def response(status, value, headers=None):
return status, headers or {}, encoded(value)
class StrictInputs(unittest.TestCase):
def test_tag_positive(self):
for value in ("v0.0.0", "v1.2.3", "v10.20.30"):
self.assertEqual(release.version(value), value[1:])
def test_tag_rejects_shell_and_noncanonical_versions(self):
for value in ("1.2.3", "v01.2.3", "v1.02.3", "v1.2.03", "v1.2.3\n",
"v1.2.3-rc1", "v1.2.3+meta", "v1.2", "v1.2.3;id", ""):
with self.subTest(value=value), self.assertRaises(release.ReleaseError):
release.version(value)
def test_sha_and_digest_are_full_lowercase(self):
self.assertEqual(release.commit(SHA), SHA)
self.assertEqual(release.sha256("sha256:" + "a" * 64), "sha256:" + "a" * 64)
for value in ("a" * 39, "A" * 40, SHA + "\n", "--help"):
with self.assertRaises(release.ReleaseError):
release.commit(value)
for value in ("sha256:abc", "sha512:" + "a" * 64, "sha256:" + "A" * 64):
with self.assertRaises(release.ReleaseError):
release.sha256(value)
def test_source_requires_primary_repo_and_approved_event(self):
with mock.patch.object(release, "git", return_value=SHA):
self.assertEqual(release.resolve_source(release.REPO, "workflow_dispatch",
"refs/heads/master", "v1.2.3"), SHA)
self.assertEqual(release.resolve_source(release.REPO, "release",
"refs/tags/v1.2.3", "v1.2.3", SHA), SHA)
for repo, event, ref in (("fork/OpenNest", "release", "refs/tags/v1.2.3"),
(release.REPO, "push", "refs/tags/v1.2.3"),
(release.REPO, "workflow_dispatch", "refs/heads/other")):
with self.assertRaises(release.ReleaseError):
release.resolve_source(repo, event, ref, "v1.2.3")
def test_source_rejects_missing_tag_or_non_master_ancestry(self):
for calls in ([release.ReleaseError("missing")], [SHA, release.ReleaseError("ancestry")]):
with mock.patch.object(release, "git", side_effect=calls):
with self.assertRaises(release.ReleaseError):
release.resolve_source(release.REPO, "release", "refs/tags/v1.2.3", "v1.2.3", SHA)
def test_source_validates_tag_before_git(self):
with mock.patch.object(release, "git") as git:
with self.assertRaises(release.ReleaseError):
release.resolve_source(release.REPO, "release", "refs/tags/x", "x;id")
git.assert_not_called()
def test_json_rejects_malformed_and_duplicate_keys(self):
for value in (b"<html>", b'{"a":1,"a":2}'):
with self.assertRaises(release.ReleaseError):
release.json_body(value)
class RegistrySafety(unittest.TestCase):
def test_only_authenticated_structured_404_is_absence(self):
for code in ("NAME_UNKNOWN", "MANIFEST_UNKNOWN"):
self.assertTrue(release.absent(*response(404, {"errors": [{"code": code}]})))
for status, body in ((401, {"errors": [{"code": "UNAUTHORIZED"}]}),
(403, {}), (429, {}), (500, {}), (404, {}),
(404, {"errors": []}),
(404, {"errors": [{"code": "DENIED"}]}),
(404, {"errors": [{"code": "NAME_UNKNOWN"}, {"code": "DENIED"}]})):
with self.subTest(status=status, body=body), self.assertRaises(release.ReleaseError):
release.absent(*response(status, body))
self.assertFalse(release.absent(*response(200, {"schemaVersion": 2})))
def test_private_associated_package(self):
package = {"name": release.PACKAGE, "package_type": "container", "visibility": "private",
"repository": {"full_name": release.REPO}}
self.assertFalse(release.package_policy(*response(200, package)))
for key, value in (("visibility", "public"), ("visibility", "internal"),
("repository", None), ("repository", {"full_name": "other/OpenNest"}),
("name", "other"), ("package_type", "npm")):
wrong = dict(package, **{key: value})
with self.assertRaises(release.ReleaseError):
release.package_policy(*response(200, wrong))
def test_package_metadata_404_never_authorizes_bootstrap(self):
with self.assertRaises(release.ReleaseError):
release.package_policy(*response(404, {
"message": "Not Found", "documentation_url": "https://docs.github.com/rest/packages"}))
for status, body in ((403, {"message": "Resource not accessible"}), (404, {}),
(404, {"message": "Not Found", "documentation_url": "https://evil.test"})):
with self.assertRaises(release.ReleaseError):
release.package_policy(*response(status, body))
def test_authenticated_scope_response_must_succeed_and_be_well_formed(self):
self.assertEqual(release.registry_token(200, encoded({"token": "opaque-v1-token"})), "opaque-v1-token")
for status, body in ((401, {}), (403, {"errors": [{"code": "DENIED"}]}),
(500, {}), (200, {}), (200, {"token": ""}), (200, {"token": "a\nb"})):
with self.assertRaises(release.ReleaseError):
release.registry_token(status, encoded(body))
def preflight_client(self, package_status=200, tags=None):
package = {"name": release.PACKAGE, "package_type": "container", "visibility": "private",
"repository": {"full_name": release.REPO}}
missing = {"message": "Not Found", "documentation_url": "https://docs.github.com/rest/packages"}
return FakeClient({
release.REPO_API: response(200, {"full_name": release.REPO, "default_branch": "master"}),
release.PACKAGE_API: response(package_status, package if package_status == 200 else missing),
release.registry_path("tags/list"): response(200, {"name": release.REGISTRY_NAME, "tags": tags or []}),
release.registry_path("manifests/1.2.3"): response(404, {"errors": [{"code": "MANIFEST_UNKNOWN"}]}),
release.registry_path("manifests/sha-" + SHA): response(404, {"errors": [{"code": "MANIFEST_UNKNOWN"}]})})
def test_preflight_checks_both_collision_tags(self):
client = self.preflight_client()
release.preflight(client, "1.2.3", SHA)
self.assertIn(release.registry_path("manifests/sha-" + SHA), client.calls)
for tag in ("1.2.3", "sha-" + SHA):
client = self.preflight_client()
client.responses[release.registry_path("manifests/" + tag)] = response(200, {})
with self.assertRaises(release.ReleaseError):
release.preflight(client, "1.2.3", SHA)
def test_missing_metadata_denies_regardless_of_registry_tags(self):
with self.assertRaises(release.ReleaseError):
release.preflight(self.preflight_client(404), "1.2.3", SHA)
with self.assertRaises(release.ReleaseError):
release.preflight(self.preflight_client(404, ["old"]), "1.2.3", SHA)
def test_preflight_transport_metadata_and_tag_list_errors_fail_closed(self):
for path in (release.REPO_API, release.PACKAGE_API, release.registry_path("tags/list")):
for bad in (release.ReleaseError("network"), response(401, {}), response(200, {})):
client = self.preflight_client()
client.responses[path] = bad
with self.subTest(path=path), self.assertRaises(release.ReleaseError):
release.preflight(client, "1.2.3", SHA)
class ReadbackSafety(unittest.TestCase):
def fixture(self):
blob = encoded(config())
manifest = {"schemaVersion": 2, "mediaType": release.MANIFEST_TYPES[0],
"config": {"digest": digest(blob), "size": len(blob)},
"layers": [{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": digest(LAYER_GZIP), "size": len(LAYER_GZIP)}]}
body = encoded(manifest)
responses = {release.registry_path("manifests/" + tag):
(200, {"docker-content-digest": digest(body)}, body)
for tag in ("1.2.3", "sha-" + SHA)}
responses[release.registry_path("blobs/" + digest(blob))] = (200, {}, blob)
responses[release.registry_path("blobs/" + digest(LAYER_GZIP))] = (200, {}, LAYER_GZIP)
local = {"Id": digest(blob), "config_digest": digest(blob), "Os": "linux", "Architecture": "amd64",
"Config": config()["config"], "RootFS": {"Type": "layers", "Layers": [digest(LAYER)]},
"image_config": config(), "rootfs_diff_ids": [digest(LAYER)]}
return FakeClient(responses), local
def test_readback_matches_both_tags_and_full_config(self):
client, local = self.fixture()
result = release.readback(client, "1.2.3", SHA, local)
self.assertEqual(result["config_digest"], local["Id"])
self.assertTrue(result["manifest_digest"].startswith("sha256:"))
self.assertNotEqual(result["manifest_digest"], result["config_digest"])
def test_tag_digest_header_body_and_config_mismatch(self):
for mode in ("tag", "header", "blob", "local"):
client, local = self.fixture()
path = release.registry_path("manifests/sha-" + SHA)
if mode in ("tag", "header"):
status, headers, body = client.responses[path]
client.responses[path] = (status, {"docker-content-digest": "sha256:" + "f" * 64}, body)
elif mode == "blob":
client.responses[release.registry_path("blobs/" + local["Id"])] = (200, {}, b"{}")
else:
local["image_config"]["config"]["User"] = "0"
with self.subTest(mode=mode), self.assertRaises(release.ReleaseError):
release.readback(client, "1.2.3", SHA, local)
def test_both_valid_but_different_tag_manifests_are_rejected(self):
client, local = self.fixture()
path = release.registry_path("manifests/sha-" + SHA)
_, _, body = client.responses[path]
changed = json.loads(body)
changed["annotations"] = {"test": "different manifest, same config"}
body = encoded(changed)
client.responses[path] = (200, {"docker-content-digest": digest(body)}, body)
with self.assertRaisesRegex(release.ReleaseError, "different digests"):
release.readback(client, "1.2.3", SHA, local)
def test_remote_platform_and_config_mismatches_even_with_valid_hashes(self):
for field, value in (("os", "windows"), ("architecture", "arm64"), ("config", {})):
client, local = self.fixture()
remote = config()
remote[field] = value
blob = encoded(remote)
local["config_digest"] = digest(blob)
manifest = {"schemaVersion": 2, "mediaType": release.MANIFEST_TYPES[0],
"config": {"digest": digest(blob), "size": len(blob)},
"layers": [{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": digest(LAYER_GZIP), "size": len(LAYER_GZIP)}]}
body = encoded(manifest)
for tag in ("1.2.3", "sha-" + SHA):
client.responses[release.registry_path("manifests/" + tag)] = (
200, {"docker-content-digest": digest(body)}, body)
client.responses[release.registry_path("blobs/" + digest(blob))] = (200, {}, blob)
with self.subTest(field=field), self.assertRaises(release.ReleaseError):
release.readback(client, "1.2.3", SHA, local)
def test_platform_labels_and_image_config_are_exact(self):
for field, value in (("Os", "windows"), ("Architecture", "arm64"), ("Id", "sha256:short")):
_, local = self.fixture()
local[field] = value
with self.assertRaises(release.ReleaseError):
release.check_local(local, "1.2.3", SHA)
for label in ("source", "version", "revision", "base.name"):
_, local = self.fixture()
local["Config"]["Labels"]["org.opencontainers.image." + label] = "wrong"
with self.assertRaises(release.ReleaseError):
release.check_local(local, "1.2.3", SHA)
def test_registry_rejects_index_and_missing_config(self):
for bad in ({"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json"},
{"schemaVersion": 2, "mediaType": release.MANIFEST_TYPES[0]}):
client, local = self.fixture()
body = encoded(bad)
for tag in ("1.2.3", "sha-" + SHA):
client.responses[release.registry_path("manifests/" + tag)] = (
200, {"docker-content-digest": digest(body)}, body)
with self.assertRaises(release.ReleaseError):
release.readback(client, "1.2.3", SHA, local)
def test_cli_failure_is_nonzero_without_secret_logging(self):
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run(["python3", str(pathlib.Path(release.__file__)), "source"],
env={"PATH": "/usr/bin", "TAG": "bad;id", "GITHUB_TOKEN": "secret-sentinel"},
capture_output=True, text=True, cwd=directory)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("secret-sentinel", result.stdout + result.stderr)
class ArchiveIdentity(unittest.TestCase):
def archive(self, path, mode="oci", mutation=None):
client, local = ReadbackSafety().fixture()
config_body = encoded(config())
manifest_body = client.responses[release.registry_path("manifests/1.2.3")][2]
descriptor = {"mediaType": release.MANIFEST_TYPES[0], "digest": digest(manifest_body), "size": len(manifest_body)}
index = {"schemaVersion": 2, "manifests": [descriptor]}
members = {"blobs/sha256/" + digest(config_body)[7:]: config_body,
"blobs/sha256/" + digest(manifest_body)[7:]: manifest_body,
"blobs/sha256/" + digest(LAYER_GZIP)[7:]: LAYER_GZIP}
if mode == "classic":
members = {"config.json": config_body, "layer.tar": LAYER,
"manifest.json": encoded([{"Config": "config.json", "Layers": ["layer.tar"]}])}
else:
if mutation == "index":
descriptor["mediaType"] = "application/vnd.oci.image.index.v1+json"
if mutation == "multiple":
index["manifests"].append(copy.deepcopy(descriptor))
members["index.json"] = encoded(index)
if mutation == "blob":
members["blobs/sha256/" + digest(LAYER_GZIP)[7:]] = b"bad layer"
if mutation == "rootfs":
local["RootFS"]["Layers"] = ["sha256:" + "c" * 64]
with tarfile.open(path, "w") as archive:
for name, body in members.items():
entry = tarfile.TarInfo(name)
entry.size = len(body)
archive.addfile(entry, io.BytesIO(body))
local["Id"] = digest(manifest_body) if mode == "oci" else digest(config_body)
if mode == "oci":
local["Descriptor"] = copy.deepcopy(descriptor)
return local
def test_archive_config_identity_on_classic_and_containerd_stores(self):
# Classic Moby also exports an OCI envelope in newer versions; its ID
# still hashes raw config, not the generated export manifest.
for mode in ("classic", "classic-oci", "oci"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
local = self.archive(path, mode)
identity = release.archive_identity(path, local)
self.assertEqual(identity["config_digest"], digest(encoded(config())))
self.assertEqual(identity["rootfs_diff_ids"], [digest(LAYER)])
self.assertEqual(local["Id"] == identity["config_digest"], mode != "oci")
def normalized_inspect(self, local):
# Moby v28.0.4 image.NewFromJSON decodes into container.Config and
# images.ImageInspect returns that struct, not RawJSON. These fields
# lack omitempty (api/types/container/config.go), so inspect emits
# their Go zero values even when the saved OCI config omits them.
# This is a source-backed fixture, not an invented hosted-run diff.
local["Config"] = dict(local["Config"], Hostname="", Domainname="",
AttachStdin=False, AttachStdout=False, AttachStderr=False,
Tty=False, OpenStdin=False, StdinOnce=False, Env=None,
Cmd=None, Image="", Volumes=None, WorkingDir="", OnBuild=None)
return local
def test_normalized_classic_inspect_keeps_exact_saved_config_identity(self):
for mode in ("classic", "classic-oci"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
local = self.normalized_inspect(self.archive(path, mode))
self.assertNotEqual(local["Config"], config()["config"])
try:
identity = release.archive_identity(path, local)
except release.ReleaseError as error:
self.fail("raw config identity must survive Moby inspect defaults: " + str(error))
self.assertEqual(identity["config_digest"], local["Id"])
self.assertEqual(identity["image_config"], config())
local.update(identity)
client, _ = ReadbackSafety().fixture()
result = release.readback(client, "1.2.3", SHA, local)
self.assertEqual(result["config_digest"], local["Id"])
def test_readback_uses_saved_config_not_normalized_inspect_config(self):
client, local = ReadbackSafety().fixture()
self.normalized_inspect(local)
try:
result = release.readback(client, "1.2.3", SHA, local)
except release.ReleaseError as error:
self.fail("remote must match saved config bytes, not reconstructed inspect: " + str(error))
self.assertEqual(result["config_digest"], digest(encoded(config())))
def test_classic_export_must_hash_to_inspected_immutable_id(self):
for mode in ("classic", "classic-oci"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
local = self.archive(path, mode)
# Same nested runtime config/platform/rootfs, different raw image
# config (created/history/etc.) must not be accepted as the ID.
local["Id"] = digest(encoded(dict(config(), created="2000-01-01T00:00:00Z")))
with self.assertRaisesRegex(release.ReleaseError, "immutable image"):
release.archive_identity(path, local)
def test_containerd_export_must_bind_inspected_descriptor_and_id(self):
for field, value in (("Id", "sha256:" + "d" * 64),
("digest", "sha256:" + "d" * 64), ("size", 1),
("size", True), ("mediaType", release.MANIFEST_TYPES[1]),
("Descriptor", None), ("Descriptor", {})):
with self.subTest(field=field, value=value), tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
local = self.archive(path)
if field in ("Id", "Descriptor"):
local[field] = value
else:
local["Descriptor"][field] = value
with self.assertRaisesRegex(release.ReleaseError, "immutable image"):
release.archive_identity(path, local)
def test_containerd_descriptor_cannot_use_legacy_archive_fallback(self):
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
local = self.archive(path, "classic")
local["Descriptor"] = {"mediaType": release.MANIFEST_TYPES[0],
"digest": local["Id"], "size": len(encoded(config()))}
with self.assertRaisesRegex(release.ReleaseError, "immutable image"):
release.archive_identity(path, local)
def test_local_identity_exports_immutable_id_and_rejects_tag_retarget(self):
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
original = self.normalized_inspect(self.archive(path, "classic"))
def export(*args):
self.assertEqual(args[:4], ("docker", "image", "save", "-o"))
self.assertEqual(args[5], original["Id"])
pathlib.Path(args[4]).write_bytes(path.read_bytes())
return ""
with mock.patch.object(release, "command", side_effect=export), \
mock.patch.object(release, "inspect", return_value=copy.deepcopy(original)) as inspect:
try:
local = release.local_identity("retargetable:tag")
except release.ReleaseError as error:
self.fail("immutable export must verify with normalized inspect: " + str(error))
self.assertEqual(local["image_config"], config())
self.assertEqual(inspect.call_args_list, [mock.call("retargetable:tag")] * 2)
changed = dict(original, Id="sha256:" + "e" * 64)
with mock.patch.object(release, "command", side_effect=export), \
mock.patch.object(release, "inspect", side_effect=[copy.deepcopy(original), changed]):
with self.assertRaisesRegex(release.ReleaseError, "changed while inspecting"):
release.local_identity("retargetable:tag")
def test_archive_rejects_indexes_attestations_corruption_and_wrong_rootfs(self):
for mutation in ("index", "multiple", "blob", "rootfs"):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "image.tar"
local = self.archive(path, mutation=mutation)
with self.assertRaises(release.ReleaseError):
release.archive_identity(path, local)
def test_remote_layers_verify_compressed_digest_and_ordered_diff_ids(self):
for mode in ("hash", "size", "diff", "encoding", "count"):
client, local = ReadbackSafety().fixture()
path = release.registry_path("manifests/1.2.3")
manifest = json.loads(client.responses[path][2])
if mode in ("hash", "size"):
client.responses[release.registry_path("blobs/" + digest(LAYER_GZIP))] = (200, {}, b"bad")
if mode == "hash":
manifest["layers"][0]["size"] = 3
elif mode == "diff":
body = gzip.compress(b"different rootfs", mtime=0)
manifest["layers"][0].update(digest=digest(body), size=len(body))
client.responses[release.registry_path("blobs/" + digest(body))] = (200, {}, body)
elif mode == "encoding":
manifest["layers"][0]["mediaType"] = "unsupported"
else:
manifest["layers"] = []
body = encoded(manifest)
for tag in ("1.2.3", "sha-" + SHA):
client.responses[release.registry_path("manifests/" + tag)] = (200, {"docker-content-digest": digest(body)}, body)
with self.subTest(mode=mode), self.assertRaises(release.ReleaseError):
release.readback(client, "1.2.3", SHA, local)
def test_pulled_cli_compares_archive_config_not_store_id(self):
_, local = ReadbackSafety().fixture()
manifest_digest = "sha256:" + "e" * 64
local["Id"] = manifest_digest
local["RepoDigests"] = [release.IMAGE + "@" + manifest_digest]
env = {"VERSION": "1.2.3", "SOURCE_SHA": SHA, "CONFIG_DIGEST": local["config_digest"],
"MANIFEST_DIGEST": manifest_digest, "LOCAL_IMAGE": local["Id"]}
with mock.patch.dict(os.environ, env), mock.patch.object(release, "git", return_value=SHA), \
mock.patch.object(release, "local_identity", return_value=local), \
mock.patch.object(release, "save") as save, mock.patch("sys.argv", ["helper", "pulled"]):
try:
release.main()
except release.ReleaseError as error:
self.fail("pulled config identity must not depend on Docker Id: " + str(error))
self.assertEqual(save.call_args.args[1]["config_digest"], env["CONFIG_DIGEST"])
with mock.patch.dict(os.environ, {"CONFIG_DIGEST": manifest_digest}), self.assertRaises(release.ReleaseError):
release.main()
def test_release_event_sha_is_validated_before_git(self):
for value in ("", SHA + "\n", SHA.upper(), SHA[:-1], " " + SHA):
with mock.patch.object(release, "git") as git, self.subTest(value=value), self.assertRaises(release.ReleaseError):
release.resolve_source(release.REPO, "release", "refs/tags/v1.2.3", "v1.2.3", value)
git.assert_not_called()
def test_remote_rootfs_config_mismatch_with_valid_blob_hashes(self):
client, local = ReadbackSafety().fixture()
remote = config()
remote["rootfs"]["diff_ids"] = ["sha256:" + "c" * 64]
blob = encoded(remote)
# Isolate the rootfs guard after the exact saved config comparison.
local["config_digest"] = digest(blob)
local["image_config"] = remote
manifest = json.loads(client.responses[release.registry_path("manifests/1.2.3")][2])
manifest["config"].update(digest=digest(blob), size=len(blob))
body = encoded(manifest)
for tag in ("1.2.3", "sha-" + SHA):
client.responses[release.registry_path("manifests/" + tag)] = (200, {"docker-content-digest": digest(body)}, body)
client.responses[release.registry_path("blobs/" + digest(blob))] = (200, {}, blob)
with self.assertRaisesRegex(release.ReleaseError, "rootfs chain"):
release.readback(client, "1.2.3", SHA, local)
def test_readback_requires_the_pre_smoke_identity_stamp(self):
client, local = ReadbackSafety().fixture()
client.responses[release.PACKAGE_API] = response(200, {"name": release.PACKAGE,
"package_type": "container", "visibility": "private", "repository": {"full_name": release.REPO}})
with tempfile.TemporaryDirectory() as directory:
env = {"VERSION": "1.2.3", "SOURCE_SHA": SHA, "METADATA_DIR": directory}
stamp = pathlib.Path(directory) / "local.json"
with mock.patch.dict(os.environ, env), mock.patch.object(release, "git", return_value=SHA), \
mock.patch.object(release, "Client", return_value=client), \
mock.patch.object(release, "local_identity", return_value=local), \
mock.patch("sys.argv", ["helper", "readback"]):
with self.assertRaisesRegex(release.ReleaseError, "identity unavailable"):
release.main()
previous = release.check_local(local, "1.2.3", SHA)
previous["image_id"] = "sha256:" + "e" * 64
stamp.write_text(json.dumps(previous))
with self.assertRaisesRegex(release.ReleaseError, "pre-smoke identity"):
release.main()
self.assertNotIn(release.registry_path("manifests/1.2.3"), client.calls)
stamp.write_text(json.dumps(release.check_local(local, "1.2.3", SHA)))
with mock.patch.object(release, "outputs"):
release.main()
self.assertEqual(json.loads((pathlib.Path(directory) / "registry.json").read_text())["config_digest"], local["config_digest"])
def test_reduced_registry_read_access_fails_before_collision_checks(self):
client = RegistrySafety().preflight_client()
client.responses[release.registry_path("tags/list")] = response(404, {"errors": [{"code": "NAME_UNKNOWN"}]})
with self.assertRaisesRegex(release.ReleaseError, "read access"):
release.preflight(client, "1.2.3", SHA)
self.assertNotIn(release.registry_path("manifests/1.2.3"), client.calls)
class SpecRegressions(unittest.TestCase):
def test_masked_package_404_denies_even_empty_or_missing_registry(self):
safety = RegistrySafety()
for registry in (response(200, {"name": release.REGISTRY_NAME, "tags": []}),
response(404, {"errors": [{"code": "NAME_UNKNOWN"}]})):
client = safety.preflight_client(404)
client.responses[release.registry_path("tags/list")] = registry
with self.subTest(registry=registry), self.assertRaises(release.ReleaseError):
release.preflight(client, "1.2.3", SHA)
self.assertNotIn(release.registry_path("tags/list"), client.calls)
def test_opaque_reduced_scope_token_cannot_authorize_masked_metadata(self):
with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "test-only", "GITHUB_REPOSITORY": release.REPO}):
for scope in (None, "", "repository:" + release.REGISTRY_NAME + ":pull"):
token = {"token": "opaque-test-only"}
if scope is not None:
token["scope"] = scope
responses = [response(200, token),
response(200, {"full_name": release.REPO, "default_branch": "master"}),
response(404, {"message": "Not Found", "documentation_url": "https://docs.github.com/rest/packages"}),
response(200, {"name": release.REGISTRY_NAME, "tags": []}),
response(404, {"errors": [{"code": "MANIFEST_UNKNOWN"}]}),
response(404, {"errors": [{"code": "MANIFEST_UNKNOWN"}]})]
with self.subTest(scope=scope), mock.patch.object(release.Client, "request", side_effect=responses):
with self.assertRaises(release.ReleaseError):
release.preflight(release.Client(), "1.2.3", SHA)
def test_store_image_id_is_not_config_digest(self):
client, local = ReadbackSafety().fixture()
local["config_digest"] = local["Id"]
local["Id"] = "sha256:" + "e" * 64 # containerd identifies the manifest, not config
try:
result = release.readback(client, "1.2.3", SHA, local)
except release.ReleaseError as error:
self.fail("same config must verify independently of Docker Id: " + str(error))
self.assertEqual(result["config_digest"], local["config_digest"])
self.assertEqual(result["image_id"], local["Id"])
def test_remote_rootfs_must_match_smoked_image(self):
client, local = ReadbackSafety().fixture()
local["RootFS"] = {"Type": "layers", "Layers": ["sha256:" + "c" * 64]}
with self.assertRaises(release.ReleaseError):
release.readback(client, "1.2.3", SHA, local)
def test_workflow_builds_without_attestations(self):
workflow = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/server-image.yml"
builds = workflow.read_text().split("docker build ")[1:]
self.assertEqual(len(builds), 2)
for build in builds:
invocation = build.split('tee "$RESULTS/build.log"')[0]
self.assertIn("--provenance=false", invocation)
self.assertIn("--sbom=false", invocation)
def test_retargeted_master_ancestor_tag_cannot_replace_release_event(self):
with tempfile.TemporaryDirectory() as directory:
def run(*args):
result = subprocess.run(["git", *args], cwd=directory, capture_output=True, text=True)
if result.returncode:
raise release.ReleaseError("git fixture failed")
return result.stdout.strip()
run("init", "-b", "master")
earlier = ""
for message in ("earlier approved release", "later master ancestor"):
run("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", message)
if message.startswith("earlier"):
earlier = run("rev-parse", "HEAD")
run("tag", "v1.2.3")
later = run("rev-parse", "HEAD")
run("update-ref", "refs/remotes/origin/master", later)
run("tag", "-f", "v1.2.3", later)
with mock.patch.object(release, "git", side_effect=run), mock.patch.dict("os.environ", {"GITHUB_SHA": earlier}):
with self.assertRaisesRegex(release.ReleaseError, "event commit"):
release.resolve_source(release.REPO, "release", "refs/tags/v1.2.3", "v1.2.3")
self.assertEqual(release.resolve_source(release.REPO, "workflow_dispatch", "refs/heads/master", "v1.2.3"), later)
class ImmutableTagCollisions(unittest.TestCase):
INDEX_TYPES = ("application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json")
def client(self):
# Exercise real Client.get, with only its transport stubbed. No login/network.
client = release.Client.__new__(release.Client)
client.github_token = "github-test-only"
client.registry_token = "registry-test-only"
return client
def test_manifest_requests_accept_all_four_image_representations(self):
expected = {"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json"}
for tag in ("1.2.3", "sha-" + SHA):
client = self.client()
path = release.registry_path("manifests/" + tag)
with self.subTest(tag=tag), mock.patch.object(client, "request", return_value=response(200, {})) as request:
self.assertEqual(client.get(path)[0], 200)
request.assert_called_once()
actual_path, headers = request.call_args.args
self.assertEqual(actual_path, path)
self.assertEqual(headers["Authorization"], "Bearer registry-test-only")
self.assertEqual({value.strip() for value in headers["Accept"].split(",")}, expected)
def listed_collision(self, tag):
client = RegistrySafety().preflight_client(tags=["old", tag])
client.responses[release.registry_path("manifests/" + tag)] = response(404, {"errors": [{
"code": "MANIFEST_UNKNOWN", "message": "OCI index found, but Accept header does not support OCI indexes"}]})
with self.assertRaisesRegex(release.ReleaseError, "existing immutable tag"):
release.preflight(client, "1.2.3", SHA)
self.assertEqual(client.calls, [release.REPO_API, release.PACKAGE_API, release.registry_path("tags/list")])
def test_listed_version_rejected_before_misleading_manifest_404(self):
self.listed_collision("1.2.3")
def test_listed_full_sha_rejected_before_misleading_manifest_404(self):
self.listed_collision("sha-" + SHA)
def unlisted_collision(self, media_type):
for tag in ("1.2.3", "sha-" + SHA):
# Model a tag created after tags/list: only manifest negotiation can see it.
responses = RegistrySafety().preflight_client(tags=["old"]).responses
client = self.client()
path = release.registry_path("manifests/" + tag)
negotiated = []
def request(actual_path, headers):
if actual_path == path:
accepted = {value.strip() for value in headers["Accept"].split(",")}
if media_type in accepted:
negotiated.append(200)
return response(200, {"schemaVersion": 2, "mediaType": media_type, "manifests": []},
{"content-type": media_type})
negotiated.append(404)
return response(404, {"errors": [{"code": "MANIFEST_UNKNOWN",
"message": "Accept header does not support stored image type"}]})
return responses[actual_path]
with self.subTest(tag=tag), mock.patch.object(client, "request", side_effect=request) as transport:
with self.assertRaisesRegex(release.ReleaseError, "existing immutable tag"):
release.preflight(client, "1.2.3", SHA)
self.assertEqual(negotiated, [200])
self.assertIn(mock.call(path, mock.ANY), transport.call_args_list)
def test_unlisted_oci_index_is_a_collision_for_either_tag(self):
self.unlisted_collision(self.INDEX_TYPES[0])
def test_unlisted_docker_manifest_list_is_a_collision_for_either_tag(self):
self.unlisted_collision(self.INDEX_TYPES[1])
def test_published_readback_still_rejects_both_index_types(self):
for media_type in self.INDEX_TYPES:
for tag in ("1.2.3", "sha-" + SHA):
fixture, local = ReadbackSafety().fixture()
body = encoded({"schemaVersion": 2, "mediaType": media_type, "manifests": []})
path = release.registry_path("manifests/" + tag)
fixture.responses[path] = (200, {"docker-content-digest": digest(body)}, body)
client = self.client()
with self.subTest(media_type=media_type, tag=tag), \
mock.patch.object(client, "request", side_effect=lambda path, headers: fixture.responses[path]):
with self.assertRaisesRegex(release.ReleaseError, "single-platform required"):
release.readback(client, "1.2.3", SHA, local)
class WorkflowBehavior(unittest.TestCase):
def test_build_log_pipeline_preserves_docker_failure(self):
workflow = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/server-image.yml"
lines = workflow.read_text().splitlines()
scripts = []
for start, line in enumerate(lines):
if line == " set -euo pipefail":
end = start
while end < len(lines) and (not lines[end] or lines[end].startswith(" ")):
end += 1
scripts.append("\n".join(value[10:] for value in lines[start:end]))
# Find build blocks by their docker command as well, so removing the
# pipefail line cannot cause the actual behavioral probes to disappear.
if not scripts:
for start, line in enumerate(lines):
if line == ' mkdir -p "$RESULTS"':
end = start
while end < len(lines) and (not lines[end] or lines[end].startswith(" ")):
end += 1
scripts.append("\n".join(value[10:] for value in lines[start:end]))
self.assertEqual(len(scripts), 2)
for script in scripts:
with tempfile.TemporaryDirectory() as directory:
env = {"PATH": "/usr/bin", "RESULTS": directory, "VERSION": "1.2.3", "SOURCE_SHA": SHA,
"SDK_IMAGE": "sdk", "RUNTIME_IMAGE": "runtime", "LOCAL_IMAGE": "local"}
prefix = "docker() { return 17; }; python3() { printf 'UNSAFE_CONTINUATION\\n'; };\n"
result = subprocess.run(["bash", "-e", "-c", prefix + script],
env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 17, result.stdout + result.stderr)
self.assertNotIn("UNSAFE_CONTINUATION", result.stdout)
class RealSourceResolution(unittest.TestCase):
def test_existing_tag_peels_to_commit_and_rejects_non_master_source(self):
with tempfile.TemporaryDirectory() as directory:
def run(*args):
result = subprocess.run(["git", *args], cwd=directory, capture_output=True, text=True)
if result.returncode:
raise release.ReleaseError("git probe rejected")
return result.stdout.strip()
run("init", "-b", "master")
run("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "base")
sha = run("rev-parse", "HEAD")
run("update-ref", "refs/remotes/origin/master", sha)
run("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "tag", "-a", "v1.2.3", "-m", "approved")
with mock.patch.object(release, "git", side_effect=run):
self.assertEqual(release.resolve_source(release.REPO, "release", "refs/tags/v1.2.3", "v1.2.3", sha), sha)
with self.assertRaises(release.ReleaseError):
release.resolve_source(release.REPO, "release", "refs/tags/v1.2.4", "v1.2.4", sha)
run("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "unmerged")
run("tag", "v1.2.4")
unmerged_sha = run("rev-parse", "HEAD")
with self.assertRaises(release.ReleaseError):
release.resolve_source(release.REPO, "release", "refs/tags/v1.2.4", "v1.2.4", unmerged_sha)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpenNest.SyntheticNests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../OpenNest.IO/OpenNest.IO.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,86 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using CncProgram = OpenNest.CNC.Program;
namespace OpenNest.SyntheticNests;
internal static class Program
{
private static void Main(string[] args)
{
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var output = args.Length == 1 ? args[0] : "test-data/synthetic-nests";
Directory.CreateDirectory(output);
Write(output, "single-triangle", new Size(20, 20),
Drawing("triangle", Polyline((0, 0), (6, 0), (2, 5)), 1));
Write(output, "paired-wedges", new Size(15, 30),
Drawing("wedge", Polyline((0, 0), (8, 0), (6, 20), (0, 20)), 2));
Write(output, "repeated-ell-fill", new Size(40, 60), Drawing("ell", Ell(), 7));
Write(output, "mixed-irregular", new Size(25, 30),
Drawing("triangle", Polyline((0, 0), (6, 0), (2, 5)), 1),
Drawing("ell", Ell(), 2),
Drawing("pentagon", Polyline((0, 0), (4, 0), (5, 3), (2, 5), (0, 3)), 3));
Write(output, "rotation-edge-fit", new Size(8.52, 3.52),
Drawing("long-bar", Rectangle(8, 3), 1));
Write(output, "multi-sheet", new Size(10, 10), Drawing("square", Rectangle(8, 8), 3));
}
private static CncProgram Ell() =>
Polyline((0, 0), (9, 0), (9, 3), (3, 3), (3, 7), (0, 7));
private static CncProgram Rectangle(double x, double y) =>
Polyline((0, 0), (x, 0), (x, y), (0, y));
private static CncProgram Polyline(params (double X, double Y)[] points)
{
var program = new CncProgram();
program.MoveTo(points[0].X, points[0].Y);
foreach (var point in points.Skip(1))
program.LineTo(point.X, point.Y);
program.LineTo(points[0].X, points[0].Y);
return program;
}
private static Drawing Drawing(string name, CncProgram program, int quantity)
{
var drawing = new Drawing(name, program);
drawing.Quantity.Required = quantity;
drawing.Constraints.AllowAnyRotation();
return drawing;
}
private static void Write(string output, string name, Size size, params Drawing[] drawings)
{
var nest = new Nest(name) { SalvageRate = 0, Units = Units.Inches };
nest.PlateDefaults.Size = size;
nest.PlateDefaults.Quadrant = 1;
nest.PlateDefaults.PartSpacing = 0.25;
nest.PlateDefaults.EdgeSpacing = new Spacing(0.25, 0.25);
foreach (var drawing in drawings)
nest.Drawings.Add(drawing);
var path = Path.Combine(output, name + ".nest");
if (!new NestWriter(nest).Write(path))
throw new IOException("Fixture write failed: " + name);
// Remove wall-clock metadata so regeneration is byte-stable. No customer data is read.
using var archive = ZipFile.Open(path, ZipArchiveMode.Update);
var entry = archive.GetEntry("nest.json");
JsonNode metadata;
using (var reader = new StreamReader(entry.Open()))
metadata = JsonNode.Parse(reader.ReadToEnd());
metadata["dateCreated"] = "2026-01-01T00:00:00";
metadata["dateLastModified"] = "2026-01-01T00:00:00";
entry.Delete();
entry = archive.CreateEntry("nest.json");
using (var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)))
writer.Write(metadata.ToJsonString());
foreach (var item in archive.Entries)
item.LastWriteTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
Console.WriteLine(name);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.