test(irregular): add six synthetic nests and bounded validity gate

This commit is contained in:
aj
2026-10-01 17:39:08 -04:00
parent c01a348243
commit d8b763da13
12 changed files with 551 additions and 0 deletions
+5
View File
@@ -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.
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Bounded validity/fulfillment gate; parallel elapsed times are not speed evidence."""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import zipfile
ROOT = Path(__file__).resolve().parents[1]
# Exact demand and sheet count: empty, skipped, and partial jobs must never pass.
EXPECTED = {
"single-triangle": (1, 1),
"paired-wedges": (2, 1),
"repeated-ell-fill": (7, 1),
"mixed-irregular": (6, 1),
"rotation-edge-fit": (1, 1),
"multi-sheet": (3, 3),
}
class GateError(Exception):
pass
def check_fixtures(directory):
files = list(directory.rglob("*.nest"))
names = [path.stem for path in files]
if len(names) != len(EXPECTED) or set(names) != set(EXPECTED):
raise GateError(f"Expected exactly six fixtures {sorted(EXPECTED)}; found {sorted(names)}")
if list(directory.rglob("*.manifest.json")):
raise GateError("Unexpected manifest in synthetic fixture directory")
hashes = {}
for path in sorted(files):
with zipfile.ZipFile(path) as archive:
metadata = json.loads(archive.read("nest.json").decode("utf-8-sig"))
drawings = metadata["drawings"]
demand = sum(drawing["quantity"]["required"] for drawing in drawings)
if demand != EXPECTED[path.stem][0] or not drawings:
raise GateError(f"Unexpected demand in {path.name}: {demand}")
if any(metadata.get(key) for key in ("customer", "madeBy")):
raise GateError(f"Non-synthetic identity metadata in {path.name}")
for drawing in drawings:
if drawing.get("customer") or drawing.get("source", {}).get("path"):
raise GateError(f"Non-synthetic drawing metadata in {path.name}")
if archive.comment or any(entry.comment for entry in archive.infolist()):
raise GateError(f"Unexpected ZIP comments in {path.name}")
hashes[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
return hashes
def check_csv(path):
seen = set()
outcomes = []
with path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
required = {"Job", "Engine", "Valid", "Crashed", "FullyPlaced", "PartsPlaced",
"PartsRequested", "PlatesUsed", "Notes"}
if not required.issubset(reader.fieldnames or []):
raise GateError("Missing benchmark CSV columns")
for row in reader:
if row["Engine"] == "Baseline":
continue
if row["Engine"] != "Irregular":
raise GateError(f"Unexpected engine: {row['Engine']}")
name = row["Job"]
if name not in EXPECTED or name in seen:
raise GateError(f"Unknown or duplicate fixture result: {name}")
seen.add(name)
demand, sheets = EXPECTED[name]
if (row["Valid"] != "True" or row["Crashed"] != "False"
or row["FullyPlaced"] != "True" or row["Notes"].strip()):
raise GateError(f"Invalid, crashed, timed-out or incomplete result: {name}: {row}")
if (int(row["PartsRequested"]) != demand or int(row["PartsPlaced"]) != demand
or int(row["PlatesUsed"]) != sheets):
raise GateError(f"Unexpected fulfillment or sheet count: {name}: {row}")
outcomes.append(row)
if seen != set(EXPECTED):
raise GateError(f"Missing fixture results: {sorted(set(EXPECTED) - seen)}")
return outcomes
def terminate_tree(process):
if os.name == "nt":
if process.poll() is None:
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
timeout=10)
else:
# Also remove any descendants left after their parent exits. The session is ours alone.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=10)
def run_process(command, deadline, log):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise GateError("Whole-check watchdog expired")
print("Running: " + " ".join(map(str, command)), flush=True)
with log.open("w", encoding="utf-8") as output:
process = subprocess.Popen(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
start_new_session=os.name != "nt",
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
try:
code = process.wait(timeout=remaining)
if code:
raise GateError(f"Subprocess exited {code}; see {log}")
except subprocess.TimeoutExpired as error:
raise GateError(f"Whole-check watchdog expired; see {log}") from error
finally:
terminate_tree(process)
def bounded_int(low, high):
def parse(value):
number = int(value)
if not low <= number <= high:
raise argparse.ArgumentTypeError(f"must be between {low} and {high}")
return number
return parse
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--parallel", type=bounded_int(1, 4), default=2)
parser.add_argument("--timeout", type=bounded_int(1, 300), default=300,
help="whole build + solve budget, seconds (maximum 300)")
parser.add_argument("--fixtures", type=Path, default=ROOT / "test-data/synthetic-nests")
parser.add_argument("--output", type=Path, help="retain logs/CSV/provenance in this directory")
parser.add_argument("--check-csv", type=Path, help="check an existing CSV without solving")
args = parser.parse_args(argv)
try:
if args.check_csv:
outcomes = check_csv(args.check_csv)
else:
deadline = time.monotonic() + args.timeout
hashes = check_fixtures(args.fixtures)
output = args.output or Path(tempfile.mkdtemp(prefix="opennest-synthetic-"))
output = output.resolve()
output.mkdir(parents=True, exist_ok=True)
(output / "accepted.json").unlink(missing_ok=True)
print(f"Evidence directory: {output}", flush=True)
project = ROOT / "OpenNest.Benchmark/OpenNest.Benchmark.csproj"
run_process(["dotnet", "build", str(project), "-c", "Release",
"--disable-build-servers"], deadline, output / "build.log")
assembly = ROOT / "OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll"
command = ["dotnet", str(assembly), str(args.fixtures.resolve()),
"--engines", "Irregular", "--parallel", str(args.parallel),
"--progress", "--csv", str(output / "results.csv")]
run_process(command, deadline, output / "benchmark.log")
outcomes = check_csv(output / "results.csv")
if check_fixtures(args.fixtures) != hashes:
raise GateError("Fixture inputs changed during the check")
(output / "accepted.json").write_text(json.dumps({
"fixtures": hashes, "parallel": args.parallel,
"benchmark_command": list(map(str, command)), "outcomes": outcomes,
"benchmark_assembly_sha256": hashlib.sha256(assembly.read_bytes()).hexdigest(),
"checker_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"timing": "diagnostic only; not speed evidence",
}, indent=2) + "\n", encoding="utf-8")
for row in sorted(outcomes, key=lambda row: row["Job"]):
print(f"PASS {row['Job']}: {row['PartsPlaced']}/{row['PartsRequested']} parts, "
f"{row['PlatesUsed']} sheet(s)")
print("PASS: exactly six Irregular results, all valid and fully placed")
return 0
except KeyboardInterrupt:
print("FAIL: cancelled; owned subprocess tree terminated", file=sys.stderr)
return 130
except (GateError, OSError, ValueError, KeyError, zipfile.BadZipFile,
subprocess.SubprocessError) as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
def cancel(_signum, _frame):
raise KeyboardInterrupt()
if __name__ == "__main__":
# Treat CI/terminal termination the same as Ctrl-C; run_process's finally owns cleanup.
signal.signal(signal.SIGTERM, cancel)
sys.exit(main())
+171
View File
@@ -0,0 +1,171 @@
"""Unit data below is intentionally synthetic CSV, not recorded benchmark evidence."""
import csv
import importlib.util
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import unittest
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("synthetic_gate", Path(__file__).with_name("check-synthetic-nests.py"))
assert spec is not None and spec.loader is not None
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class SyntheticGateTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="opennest-gate-test-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.csv = self.root / "results.csv"
self.rows = [dict(Job=name, Engine="Irregular", Valid="True", Crashed="False",
FullyPlaced="True", PartsPlaced=str(demand), PartsRequested=str(demand),
PlatesUsed=str(sheets), Notes="")
for name, (demand, sheets) in gate.EXPECTED.items()]
def write_csv(self, rows):
with self.csv.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=list(self.rows[0]))
writer.writeheader()
writer.writerows(rows)
def test_exact_six_and_baseline_filter(self):
self.write_csv(self.rows + [dict(self.rows[0], Engine="Baseline", Valid="False")])
self.assertEqual(6, len(gate.check_csv(self.csv)))
def test_fail_closed_result_cases(self):
cases = {
"invalid": dict(Valid="False"),
"crashed": dict(Crashed="True", Notes="exception"),
"timeout": dict(Valid="False", Notes="Timed out after 5 minutes"),
"incomplete": dict(FullyPlaced="False", PartsPlaced="0"),
"empty": dict(PartsPlaced="0", PartsRequested="0", PlatesUsed="0"),
"unknown": dict(Job="unknown"),
"engine": dict(Engine="Unknown"),
"demand": dict(PartsRequested="99"),
"sheets": dict(PlatesUsed="0"),
"notes": dict(Notes="validation warning"),
}
for label, change in cases.items():
with self.subTest(label=label):
self.write_csv([dict(self.rows[0], **change)] + self.rows[1:])
with self.assertRaises(gate.GateError):
gate.check_csv(self.csv)
for rows in ([], self.rows[:-1], self.rows + [self.rows[0]]):
self.write_csv(rows)
with self.assertRaises(gate.GateError):
gate.check_csv(self.csv)
def test_malformed_csv_returns_nonzero(self):
self.write_csv([dict(self.rows[0], PartsPlaced="not-a-number")] + self.rows[1:])
self.assertEqual(1, gate.main(["--check-csv", str(self.csv)]))
self.csv.write_text("Job,Engine\n", encoding="utf-8")
self.assertEqual(1, gate.main(["--check-csv", str(self.csv)]))
def test_missing_fixture_actual_entrypoint(self):
self.assertEqual(1, gate.main(["--fixtures", str(self.root)]))
def test_committed_fixtures_are_complete_and_synthetic(self):
self.assertEqual(6, len(gate.check_fixtures(gate.ROOT / "test-data/synthetic-nests")))
def test_concurrency_and_watchdog_are_bounded(self):
for args in (["--parallel", "0"], ["--parallel", "5"], ["--timeout", "301"]):
with self.assertRaises(SystemExit) as error:
gate.main(args)
self.assertEqual(2, error.exception.code)
def test_nonzero_child_fails(self):
script = self.root / "exit.py"
script.write_text("raise SystemExit(7)\n", encoding="utf-8")
with self.assertRaisesRegex(gate.GateError, "exited 7"):
gate.run_process([sys.executable, str(script)], time.monotonic() + 10,
self.root / "exit.log")
@unittest.skipIf(os.name == "nt", "POSIX SIGTERM probe; Windows requires runtime acceptance")
def test_cancellation_kills_ready_owned_tree(self):
parent = self.root / "cancel-parent.py"
pidfile = self.root / "cancel-pids.txt"
ready = self.root / "cancel-ready"
parent.write_text(
"import os, subprocess, sys, time\n"
"from pathlib import Path\n"
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
f"Path({str(pidfile)!r}).write_text(str(os.getpid()) + ' ' + str(child.pid))\n"
f"Path({str(ready)!r}).touch()\n"
"time.sleep(60)\n", encoding="utf-8")
wrapper = self.root / "cancel-wrapper.py"
wrapper.write_text(
"import importlib.util, signal, sys, time\n"
"from pathlib import Path\n"
"sys.dont_write_bytecode = True\n"
f"spec = importlib.util.spec_from_file_location('gate', {str(Path(__file__).with_name('check-synthetic-nests.py'))!r})\n"
"gate = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(gate)\n"
"signal.signal(signal.SIGTERM, gate.cancel)\n"
"try:\n"
f" gate.run_process([sys.executable, {str(parent)!r}], time.monotonic() + 15, "
f"Path({str(self.root / 'cancel.log')!r}))\n"
"except KeyboardInterrupt:\n sys.exit(130)\n", encoding="utf-8")
process = subprocess.Popen([sys.executable, str(wrapper)], start_new_session=True)
try:
end = time.monotonic() + 10
while not ready.exists() and time.monotonic() < end:
time.sleep(0.01)
self.assertTrue(ready.exists(), "cancellation tree never became ready")
pids = [int(value) for value in pidfile.read_text().split()]
process.send_signal(signal.SIGTERM)
self.assertEqual(130, process.wait(timeout=10))
self.assert_quiescent(pids)
finally:
gate.terminate_tree(process)
def assert_quiescent(self, pids):
for pid in pids:
end = time.monotonic() + 5
while True:
status = Path(f"/proc/{pid}/stat")
if status.exists() and status.read_text().split()[2] == "Z":
break # Linux init may not yet have reaped a killed orphan.
try:
os.kill(pid, 0)
except ProcessLookupError:
break
self.assertLess(time.monotonic(), end, f"owned process {pid} remains alive")
time.sleep(0.01)
@unittest.skipIf(os.name == "nt", "POSIX session-tree probe; Windows uses taskkill /T /F")
def test_watchdog_kills_ready_parent_and_grandchild(self):
# A real owned process tree, synchronized before the watchdog. No benchmark results mocked.
child = self.root / "child.py"
child.write_text("import time\ntime.sleep(60)\n", encoding="utf-8")
parent = self.root / "parent.py"
pidfile = self.root / "pids.txt"
ready = self.root / "ready"
parent.write_text(
"import os, subprocess, sys, time\n"
"from pathlib import Path\n"
f"child = subprocess.Popen([sys.executable, {str(child)!r}])\n"
f"Path({str(pidfile)!r}).write_text(str(os.getpid()) + ' ' + str(child.pid))\n"
f"Path({str(ready)!r}).touch()\n"
"time.sleep(60)\n", encoding="utf-8")
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(gate.run_process, [sys.executable, str(parent)],
time.monotonic() + 5, self.root / "timeout.log")
end = time.monotonic() + 4
while not ready.exists() and time.monotonic() < end:
time.sleep(0.01)
self.assertTrue(ready.exists(), "child tree never became ready")
pids = [int(value) for value in pidfile.read_text().split()]
with self.assertRaisesRegex(gate.GateError, "watchdog expired"):
future.result(timeout=10)
self.assert_quiescent(pids)
if __name__ == "__main__":
unittest.main()
@@ -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.