mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 14:48:50 -04:00
test(irregular): add six synthetic nests and bounded validity gate
This commit is contained in:
@@ -23,5 +23,10 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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,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