mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-03 03:32:11 -04:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761cee7c1f | ||
|
|
c49c387af5 | ||
|
|
9e15d5dceb | ||
|
|
eace08f351 | ||
|
|
96267e760e | ||
|
|
646d4c3975 | ||
|
|
1fadf6c4d3 | ||
|
|
f53bead0a8 | ||
|
|
1ec79ae594 | ||
|
|
d428357c8b | ||
|
|
142c77a27f | ||
|
|
b39509a6a3 | ||
|
|
6d1e0cb896 | ||
|
|
5d8874a9e7 | ||
|
|
b17fe1a318 | ||
|
|
7999e3cdde | ||
|
|
14a7ce43a7 | ||
|
|
bd5db59e42 | ||
|
|
25755a8850 | ||
|
|
c7b4ba4457 | ||
|
|
d8b763da13 | ||
|
|
c01a348243 | ||
|
|
e886bcfa35 | ||
|
|
4fa0f636ce | ||
|
|
94e23e4b7a |
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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." }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { }
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 > Open (Database mode): lists nests from the shared nest server with
|
/// File > 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Executable
+338
@@ -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'
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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.
Reference in new issue
Block a user