Add transport-free JCZ list buffering
This commit is contained in:
parent
47d83684ee
commit
bf5dd439ce
9 changed files with 220 additions and 21 deletions
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
The transport-free JCZ semantic encoder rejected all six synthetic negative
|
The transport-free JCZ semantic encoder rejected all eight synthetic negative
|
||||||
cases before producing output:
|
cases before producing output:
|
||||||
|
|
||||||
| Case | Rejection |
|
| Case | Rejection |
|
||||||
|
|
@ -17,9 +17,11 @@ cases before producing output:
|
||||||
| Zero frequency | Frequency was not positive |
|
| Zero frequency | Frequency was not positive |
|
||||||
| Zero pulse width | Pulse width was not positive |
|
| Zero pulse width | Pulse width was not positive |
|
||||||
| Unsupported command | A synthetic `CUSTOM` Ops command had no explicit mapping |
|
| Unsupported command | A synthetic `CUSTOM` Ops command had no explicit mapping |
|
||||||
|
| Missing required state | A job omitted pulse width, rapid speed, frequency and mark speed |
|
||||||
|
| State after motion | A frequency change appeared after the first move |
|
||||||
|
|
||||||
Two runs produced identical results with SHA-256
|
Two runs produced identical results with SHA-256
|
||||||
`0adab8fb1060fde1e172c674da3d0d4ae5936b01490891b898f86cc4f7ca3c3f`.
|
`1372037e089185365bb7600762d49afa938063ef53f888604f87c68cad829b83`.
|
||||||
The checked result is `tests/fixtures/rayforge-jcz-negative-cases.json`.
|
The checked result is `tests/fixtures/rayforge-jcz-negative-cases.json`.
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,16 @@ The opcode and value fields agree with the pinned BalorMK mock trace for power,
|
||||||
mark and jump speed, frequency, MOPA pulse width, the square coordinates and
|
mark and jump speed, frequency, MOPA pulse width, the square coordinates and
|
||||||
distances, one millisecond dwell, and end-of-list.
|
distances, one millisecond dwell, and end-of-list.
|
||||||
|
|
||||||
|
The encoder now places those records into the pinned BalorMK list shape:
|
||||||
|
exactly 256 records and 3072 bytes per buffer, with unused final-buffer slots
|
||||||
|
filled by `listEndOfList`. Before packing, it requires one complete state block
|
||||||
|
ahead of all motion and emits the evidence-backed order `listReadyMark`, pulse
|
||||||
|
width, jump speed, frequency, power and mark speed, followed by geometry and
|
||||||
|
end-of-list. State changes after motion and duplicate or missing state fields
|
||||||
|
fail closed.
|
||||||
|
|
||||||
Two independent runs produced identical output with SHA-256
|
Two independent runs produced identical output with SHA-256
|
||||||
`b3d34600daac7060c3d3b28edf1627813d3bc518b1a64be11367f8e2106a51c6`.
|
`6217c9f8fee0e612872de6c20f5b033a4df885a173a442baa54629465fc5db5b`.
|
||||||
The checked records are in
|
The checked records are in
|
||||||
`tests/fixtures/rayforge-jcz-equivalence.json` and are asserted byte-for-byte by
|
`tests/fixtures/rayforge-jcz-equivalence.json` and are asserted byte-for-byte by
|
||||||
the standard-library test suite.
|
the standard-library test suite.
|
||||||
|
|
@ -29,10 +37,9 @@ operations continue to fail closed.
|
||||||
|
|
||||||
## Remaining gaps
|
## Remaining gaps
|
||||||
|
|
||||||
- Records are emitted in Rayforge operation order. A production adapter must
|
- Upload, status, cancellation and recovery are absent.
|
||||||
define controller-safe state ordering from evidence.
|
- Port-state commands are deliberately omitted because their machine-specific
|
||||||
- List framing, fixed-size buffers, padding, upload, status, cancellation and
|
bit semantics can arm output and have not yet passed the hardware gate.
|
||||||
recovery are absent.
|
|
||||||
- The 110 mm coordinate mapping is synthetic and has no lens correction.
|
- The 110 mm coordinate mapping is synthetic and has no lens correction.
|
||||||
- Machine-specific bounds and the pulse-width unit contract still require
|
- Machine-specific bounds and the pulse-width unit contract still require
|
||||||
authoritative evidence; they are not guessed here.
|
authoritative evidence; they are not guessed here.
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,11 @@ the no-device JCZ driver, preserves process values as raw observations with
|
||||||
unit uncertainty, and keeps encoding disabled until the remaining profile and
|
unit uncertainty, and keeps encoding disabled until the remaining profile and
|
||||||
project evidence exists.
|
project evidence exists.
|
||||||
|
|
||||||
|
Item 4 now covers transport-free fixed-size list buffering and an explicit
|
||||||
|
state-before-motion order derived from pinned BalorMK. Port commands, upload,
|
||||||
|
execute, status and cancellation remain outside the encoder and cannot be
|
||||||
|
reached by this spike.
|
||||||
|
|
||||||
## Decision rules
|
## Decision rules
|
||||||
|
|
||||||
- A simulator test precedes every equivalent hardware behavior.
|
- A simulator test precedes every equivalent hardware behavior.
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,24 @@ def run(source: Path, addon: Path, balor_fixture: Path) -> dict[str, object]:
|
||||||
"rayforge_ops_count": ops.len(),
|
"rayforge_ops_count": ops.len(),
|
||||||
"semantic_trace": semantic,
|
"semantic_trace": semantic,
|
||||||
"command_records_hex": encoded.driver_data["command_records_hex"],
|
"command_records_hex": encoded.driver_data["command_records_hex"],
|
||||||
|
"controller_list_commands": [
|
||||||
|
item["command"]
|
||||||
|
for item in encoded.driver_data["controller_list_trace"]
|
||||||
|
],
|
||||||
|
"list_buffer_size": encoded.driver_data["list_buffer_size"],
|
||||||
|
"list_record_capacity": encoded.driver_data["list_record_capacity"],
|
||||||
|
"list_buffer_count": len(encoded.driver_data["list_buffers"]),
|
||||||
|
"list_buffer_sha256": encoded.driver_data["list_buffer_sha256"],
|
||||||
|
"list_meaningful_record_count": encoded.driver_data[
|
||||||
|
"list_meaningful_record_count"
|
||||||
|
],
|
||||||
|
"list_padding_record_count": encoded.driver_data[
|
||||||
|
"list_padding_record_count"
|
||||||
|
],
|
||||||
"balormk_equivalence": checks,
|
"balormk_equivalence": checks,
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"12-byte command records only; no list buffering or transport",
|
"3072-byte list buffers only; no upload or transport",
|
||||||
"record ordering follows the Rayforge Ops stream",
|
"safe state ordering follows pinned BalorMK program/set_settings order while omitting port commands",
|
||||||
"synthetic field mapping; no lens correction",
|
"synthetic field mapping; no lens correction",
|
||||||
"pulse width treated as nanoseconds from LaserStep UI contract",
|
"pulse width treated as nanoseconds from LaserStep UI contract",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,40 @@ def run(source: Path, addon: Path) -> dict[str, object]:
|
||||||
"unsupported_ops_command", Encoder(), unsupported, "unsupported"
|
"unsupported_ops_command", Encoder(), unsupported, "unsupported"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
incomplete_state = Ops()
|
||||||
|
incomplete_state.job_start()
|
||||||
|
incomplete_state.set_power(0.5)
|
||||||
|
incomplete_state.job_end()
|
||||||
|
expect_rejection(
|
||||||
|
"missing_required_state",
|
||||||
|
Encoder(),
|
||||||
|
incomplete_state,
|
||||||
|
"missing required JCZ state",
|
||||||
|
)
|
||||||
|
|
||||||
|
late_state = Ops()
|
||||||
|
late_state.job_start()
|
||||||
|
late_state.set_power(0.5)
|
||||||
|
late_state.set_feed_rate(15_000)
|
||||||
|
late_state.set_rapid_rate(120_000)
|
||||||
|
late_state.set_frequency(40_000)
|
||||||
|
late_state.set_pulse_width(200)
|
||||||
|
late_state.move_to(55, 55, 0)
|
||||||
|
late_state.set_frequency(50_000)
|
||||||
|
late_state.job_end()
|
||||||
|
expect_rejection(
|
||||||
|
"state_change_after_motion",
|
||||||
|
Encoder(),
|
||||||
|
late_state,
|
||||||
|
"state change after motion",
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"rayforge_commit": commit,
|
"rayforge_commit": commit,
|
||||||
"profile": Encoder.SYNTHETIC_PROFILE_ID,
|
"profile": Encoder.SYNTHETIC_PROFILE_ID,
|
||||||
"transport": None,
|
"transport": None,
|
||||||
"cases": cases,
|
"cases": cases,
|
||||||
"all_failed_closed": len(cases) == 6,
|
"all_failed_closed": len(cases) == 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ This is deliberately not a controller implementation. It has no discovery
|
||||||
specification, transport, USB import, or machine I/O.
|
specification, transport, USB import, or machine I/O.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
|
|
@ -35,8 +36,20 @@ class OfflineJCZEncoder(OpsEncoder):
|
||||||
"power": 0x8012,
|
"power": 0x8012,
|
||||||
"mark_speed": 0x800C,
|
"mark_speed": 0x800C,
|
||||||
"pulse_width": 0x8026,
|
"pulse_width": 0x8026,
|
||||||
|
"ready": 0x8051,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LIST_RECORD_SIZE = 12
|
||||||
|
LIST_BUFFER_SIZE = 0xC00
|
||||||
|
LIST_RECORD_CAPACITY = LIST_BUFFER_SIZE // LIST_RECORD_SIZE
|
||||||
|
STATE_ORDER = (
|
||||||
|
"pulse_width",
|
||||||
|
"jump_speed",
|
||||||
|
"frequency",
|
||||||
|
"power",
|
||||||
|
"mark_speed",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
profile_id: str = SYNTHETIC_PROFILE_ID,
|
profile_id: str = SYNTHETIC_PROFILE_ID,
|
||||||
|
|
@ -66,7 +79,9 @@ class OfflineJCZEncoder(OpsEncoder):
|
||||||
command = item["command"]
|
command = item["command"]
|
||||||
if command == "job_start":
|
if command == "job_start":
|
||||||
continue
|
continue
|
||||||
if command == "job_end":
|
if command == "ready":
|
||||||
|
records.append(self._record(self.OPCODES[command]))
|
||||||
|
elif command == "job_end":
|
||||||
records.append(self._record(self.OPCODES["end_of_list"]))
|
records.append(self._record(self.OPCODES["end_of_list"]))
|
||||||
elif command == "pulse_width":
|
elif command == "pulse_width":
|
||||||
records.append(
|
records.append(
|
||||||
|
|
@ -100,6 +115,58 @@ class OfflineJCZEncoder(OpsEncoder):
|
||||||
raise ValueError(f"cannot packetize semantic command: {command}")
|
raise ValueError(f"cannot packetize semantic command: {command}")
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
def _controller_list_trace(
|
||||||
|
self, trace: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not trace or trace[0]["command"] != "job_start":
|
||||||
|
raise ValueError("JCZ list must start with job_start")
|
||||||
|
if trace[-1]["command"] != "job_end":
|
||||||
|
raise ValueError("JCZ list must end with job_end")
|
||||||
|
|
||||||
|
states: dict[str, dict[str, Any]] = {}
|
||||||
|
body: list[dict[str, Any]] = []
|
||||||
|
motion_seen = False
|
||||||
|
for item in trace[1:-1]:
|
||||||
|
command = item["command"]
|
||||||
|
if command in self.STATE_ORDER:
|
||||||
|
if motion_seen:
|
||||||
|
raise ValueError(
|
||||||
|
"JCZ state change after motion is not supported"
|
||||||
|
)
|
||||||
|
if command in states:
|
||||||
|
raise ValueError(f"duplicate JCZ state command: {command}")
|
||||||
|
states[command] = item
|
||||||
|
else:
|
||||||
|
motion_seen = True
|
||||||
|
body.append(item)
|
||||||
|
|
||||||
|
missing = [name for name in self.STATE_ORDER if name not in states]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"missing required JCZ state: {', '.join(missing)}")
|
||||||
|
return (
|
||||||
|
[{"command": "ready"}]
|
||||||
|
+ [states[name] for name in self.STATE_ORDER]
|
||||||
|
+ body
|
||||||
|
+ [trace[-1]]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pack_list_buffers(self, records: list[bytes]) -> list[bytes]:
|
||||||
|
if not records:
|
||||||
|
return []
|
||||||
|
end_record = self._record(self.OPCODES["end_of_list"])
|
||||||
|
buffers = []
|
||||||
|
for offset in range(0, len(records), self.LIST_RECORD_CAPACITY):
|
||||||
|
chunk = records[offset : offset + self.LIST_RECORD_CAPACITY]
|
||||||
|
if len(chunk) < self.LIST_RECORD_CAPACITY:
|
||||||
|
chunk = chunk + [end_record] * (
|
||||||
|
self.LIST_RECORD_CAPACITY - len(chunk)
|
||||||
|
)
|
||||||
|
packet = b"".join(chunk)
|
||||||
|
if len(packet) != self.LIST_BUFFER_SIZE:
|
||||||
|
raise AssertionError("JCZ list buffer is not exactly 0xC00 bytes")
|
||||||
|
buffers.append(packet)
|
||||||
|
return buffers
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _coordinate(cls, value_mm: float) -> int:
|
def _coordinate(cls, value_mm: float) -> int:
|
||||||
if not 0.0 <= value_mm <= cls.FIELD_MM:
|
if not 0.0 <= value_mm <= cls.FIELD_MM:
|
||||||
|
|
@ -186,7 +253,9 @@ class OfflineJCZEncoder(OpsEncoder):
|
||||||
raise ValueError(f"unsupported Ops command: {kind}")
|
raise ValueError(f"unsupported Ops command: {kind}")
|
||||||
op_count = len(trace)
|
op_count = len(trace)
|
||||||
|
|
||||||
records = self._encode_records(trace)
|
controller_trace = [] if ops is None else self._controller_list_trace(trace)
|
||||||
|
records = self._encode_records(controller_trace)
|
||||||
|
list_buffers = self._pack_list_buffers(records)
|
||||||
payload = b"".join(records)
|
payload = b"".join(records)
|
||||||
if ops is None:
|
if ops is None:
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
|
|
@ -202,6 +271,18 @@ class OfflineJCZEncoder(OpsEncoder):
|
||||||
"binary": payload,
|
"binary": payload,
|
||||||
"command_records_hex": [record.hex() for record in records],
|
"command_records_hex": [record.hex() for record in records],
|
||||||
"semantic_trace": trace,
|
"semantic_trace": trace,
|
||||||
|
"controller_list_trace": controller_trace,
|
||||||
|
"list_buffers": list_buffers,
|
||||||
|
"list_buffer_sha256": [
|
||||||
|
hashlib.sha256(packet).hexdigest()
|
||||||
|
for packet in list_buffers
|
||||||
|
],
|
||||||
|
"list_record_capacity": self.LIST_RECORD_CAPACITY,
|
||||||
|
"list_buffer_size": self.LIST_BUFFER_SIZE,
|
||||||
|
"list_meaningful_record_count": len(records),
|
||||||
|
"list_padding_record_count": (
|
||||||
|
len(list_buffers) * self.LIST_RECORD_CAPACITY - len(records)
|
||||||
|
),
|
||||||
"transport": None,
|
"transport": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
34
tests/fixtures/rayforge-jcz-equivalence.json
vendored
34
tests/fixtures/rayforge-jcz-equivalence.json
vendored
|
|
@ -20,11 +20,12 @@
|
||||||
{"command": "job_end"}
|
{"command": "job_end"}
|
||||||
],
|
],
|
||||||
"command_records_hex": [
|
"command_records_hex": [
|
||||||
"128000080000000000000000",
|
"518000000000000000000000",
|
||||||
"0c8094000000000000000000",
|
"2680c8000000000000000000",
|
||||||
"0680a7040000000000000000",
|
"0680a7040000000000000000",
|
||||||
"1b80f4010000000000000000",
|
"1b80f4010000000000000000",
|
||||||
"2680c8000000000000000000",
|
"128000080000000000000000",
|
||||||
|
"0c8094000000000000000000",
|
||||||
"0180007000700000a0160000",
|
"0180007000700000a0160000",
|
||||||
"058000710070000000010000",
|
"058000710070000000010000",
|
||||||
"058000710071000000010000",
|
"058000710071000000010000",
|
||||||
|
|
@ -33,6 +34,29 @@
|
||||||
"038064000000000000000000",
|
"038064000000000000000000",
|
||||||
"028000000000000000000000"
|
"028000000000000000000000"
|
||||||
],
|
],
|
||||||
|
"controller_list_commands": [
|
||||||
|
"ready",
|
||||||
|
"pulse_width",
|
||||||
|
"jump_speed",
|
||||||
|
"frequency",
|
||||||
|
"power",
|
||||||
|
"mark_speed",
|
||||||
|
"jump",
|
||||||
|
"mark",
|
||||||
|
"mark",
|
||||||
|
"mark",
|
||||||
|
"mark",
|
||||||
|
"laser_on_point",
|
||||||
|
"job_end"
|
||||||
|
],
|
||||||
|
"list_buffer_size": 3072,
|
||||||
|
"list_record_capacity": 256,
|
||||||
|
"list_buffer_count": 1,
|
||||||
|
"list_buffer_sha256": [
|
||||||
|
"df9894c746df451054d7237e1e020153e2a65cf2ceaea71dde7d9a40fabaf3e1"
|
||||||
|
],
|
||||||
|
"list_meaningful_record_count": 13,
|
||||||
|
"list_padding_record_count": 243,
|
||||||
"balormk_equivalence": {
|
"balormk_equivalence": {
|
||||||
"power_50_percent": true,
|
"power_50_percent": true,
|
||||||
"speed_250_mm_s": true,
|
"speed_250_mm_s": true,
|
||||||
|
|
@ -42,8 +66,8 @@
|
||||||
"dwell_1_ms": true
|
"dwell_1_ms": true
|
||||||
},
|
},
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"12-byte command records only; no list buffering or transport",
|
"3072-byte list buffers only; no upload or transport",
|
||||||
"record ordering follows the Rayforge Ops stream",
|
"safe state ordering follows pinned BalorMK program/set_settings order while omitting port commands",
|
||||||
"synthetic field mapping; no lens correction",
|
"synthetic field mapping; no lens correction",
|
||||||
"pulse width treated as nanoseconds from LaserStep UI contract"
|
"pulse width treated as nanoseconds from LaserStep UI contract"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
10
tests/fixtures/rayforge-jcz-negative-cases.json
vendored
10
tests/fixtures/rayforge-jcz-negative-cases.json
vendored
|
|
@ -32,6 +32,16 @@
|
||||||
"case": "unsupported_ops_command",
|
"case": "unsupported_ops_command",
|
||||||
"result": "rejected",
|
"result": "rejected",
|
||||||
"reason": "unsupported Ops command: CUSTOM"
|
"reason": "unsupported Ops command: CUSTOM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "missing_required_state",
|
||||||
|
"result": "rejected",
|
||||||
|
"reason": "missing required JCZ state: pulse_width, jump_speed, frequency, mark_speed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "state_change_after_motion",
|
||||||
|
"result": "rejected",
|
||||||
|
"reason": "JCZ state change after motion is not supported"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"all_failed_closed": true
|
"all_failed_closed": true
|
||||||
|
|
|
||||||
|
|
@ -62,11 +62,12 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
||||||
self.assertTrue(all(len(record) == 24 for record in records))
|
self.assertTrue(all(len(record) == 24 for record in records))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
"128000080000000000000000",
|
"518000000000000000000000",
|
||||||
"0c8094000000000000000000",
|
"2680c8000000000000000000",
|
||||||
"0680a7040000000000000000",
|
"0680a7040000000000000000",
|
||||||
"1b80f4010000000000000000",
|
"1b80f4010000000000000000",
|
||||||
"2680c8000000000000000000",
|
"128000080000000000000000",
|
||||||
|
"0c8094000000000000000000",
|
||||||
"0180007000700000a0160000",
|
"0180007000700000a0160000",
|
||||||
"058000710070000000010000",
|
"058000710070000000010000",
|
||||||
"058000710071000000010000",
|
"058000710071000000010000",
|
||||||
|
|
@ -78,11 +79,38 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
||||||
records,
|
records,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_controller_list_is_fixed_size_and_safely_ordered(self):
|
||||||
|
result = json.loads(EQUIVALENCE_FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(3072, result["list_buffer_size"])
|
||||||
|
self.assertEqual(256, result["list_record_capacity"])
|
||||||
|
self.assertEqual(1, result["list_buffer_count"])
|
||||||
|
self.assertEqual(1, len(result["list_buffer_sha256"]))
|
||||||
|
self.assertEqual(13, result["list_meaningful_record_count"])
|
||||||
|
self.assertEqual(243, result["list_padding_record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ready",
|
||||||
|
"pulse_width",
|
||||||
|
"jump_speed",
|
||||||
|
"frequency",
|
||||||
|
"power",
|
||||||
|
"mark_speed",
|
||||||
|
"jump",
|
||||||
|
"mark",
|
||||||
|
"mark",
|
||||||
|
"mark",
|
||||||
|
"mark",
|
||||||
|
"laser_on_point",
|
||||||
|
"job_end",
|
||||||
|
],
|
||||||
|
result["controller_list_commands"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_negative_cases_all_fail_closed_without_transport(self):
|
def test_negative_cases_all_fail_closed_without_transport(self):
|
||||||
result = json.loads(NEGATIVE_FIXTURE.read_text(encoding="utf-8"))
|
result = json.loads(NEGATIVE_FIXTURE.read_text(encoding="utf-8"))
|
||||||
self.assertTrue(result["all_failed_closed"])
|
self.assertTrue(result["all_failed_closed"])
|
||||||
self.assertIsNone(result["transport"])
|
self.assertIsNone(result["transport"])
|
||||||
self.assertEqual(6, len(result["cases"]))
|
self.assertEqual(8, len(result["cases"]))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"rejected"}, {case["result"] for case in result["cases"]}
|
{"rejected"}, {case["result"] for case in result["cases"]}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue