mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
forgetest tests: the confirmed trims of the tool's own unit tests
The cases that tested a mock, a timer, a wording or a table, or that
replayed a judgment a host test already holds, leave forgetest/tests.
What catches a false PASS, keeps the bench safe, or guards the gate
stays.
- test_baseline: the fake sysfs tree is a mixin with no test_ methods,
so ExtensionFreeTests and BaselineModeTests no longer re-run
BaselineTests' 25 cases (133 runs, now 45). CoolPublishTests, the
lamp-fade cases and TransientNotLeftoverTests go; PositionDeadbandTests
keeps a reading past the band and an unreadable reading, both never
excused. Both latch-relock cases stay.
- test_fixture: MdnsTests, the three timing cases, the probe's log
wording, and nine prompt-routing variants go. A live test never runs
unattended, the arm-press opt-in, the short-press edge, the presence
press handing over the arm press, and the arm notice dropping at the
press stay (35 cases, now 21).
- test_cloud_suite: three false-PASS guards stay: the latch open at a
dark print's button, a lid stop that is not edge-driven, the kernel
refusing the resume. The guards for the latch unlocking in the hold
and a paused print resuming left with their catalog tests in 6a434bb.
The rest go, with the harness pieces and the hunt-lid fixture only
they read (41 cases, now 3).
- test_cooling_suite: GateOffTests, CriticalTierTests, FanGateTests and
FansQuietTests go with their harness; the flow verdict's log reads
stay: only the text after the offset, never an older verdict before it
(the flow-under-load false-PASS guard), a rotated log read whole.
- test_queue: RunClockTests and three ordering cases go, and refusals
are judged by a reason given, not its words; both live
acknowledgment cases stay.
- test_takeover_position: the six cases that judge the head and move
nothing stay.
- test_camera_home_return: the five cases where the head must not move
stay, and so do the three that check the hand-back jog's direction and
distance (homed, unhomed with the counters not counted twice, after
cloud mode): a wrong sign or a double count drives the head into the
gantry stops (15 cases, now 8).
- test_server: the keep-alive and ETag cases, the takeover and scope
half of the bench case, and the page's self-contained asserts
(test_page.py's) go.
- test_operator: the wording cases, the fixture case (test_fixture.py's)
and JournalTests go.
- test_mode: the supervisor timeout table goes.
- test_takeover_client: the listener, the offline service back, the
online client without the hunt, and GRBL under no marker stay.
- test_responsiveness: PageTests and ImplementationCostTests go; the
log cache keeps appends, a partial line, a replaced file and a fresh
list, and the fingerprint cache a changed manifest and one with no
content hash.
- test_bench_registry: GfbenchTests and two description and argument
pins go.
- test_installer: the log-format and TLS-wording cases and
InstallerShapeTests go; the die case checks the reason is in the log,
not its format.
- test_image: KernelIdentTests and the registration pin go.
Each file's __main__ guard now ends the file, and the docstrings say what
each still proves.
Proof: each of the fifteen files passes under Linux as forgetest-ci
discovers it, and pyflakes reports nothing new in them.
Acceptance: none; test-only.
This commit is contained in:
-207
@@ -1,207 +0,0 @@
|
||||
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
|
||||
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
|
||||
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
|
||||
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
|
||||
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
|
||||
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
|
||||
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
|
||||
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
|
||||
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
|
||||
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
|
||||
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
|
||||
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
|
||||
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
|
||||
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
|
||||
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
|
||||
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
|
||||
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
|
||||
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
|
||||
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
|
||||
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
|
||||
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
|
||||
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
|
||||
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
|
||||
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
|
||||
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
|
||||
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
|
||||
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
|
||||
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
|
||||
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
||||
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
|
||||
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
|
||||
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
|
||||
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
|
||||
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
|
||||
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
|
||||
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
|
||||
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
|
||||
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
|
||||
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
|
||||
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
|
||||
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
|
||||
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
|
||||
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
|
||||
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
|
||||
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
|
||||
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
|
||||
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
|
||||
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
|
||||
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
|
||||
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
|
||||
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
|
||||
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
|
||||
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
|
||||
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
|
||||
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
|
||||
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
@@ -21,7 +21,10 @@ from forgetest import baseline
|
||||
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||
|
||||
|
||||
class BaselineTests(unittest.TestCase):
|
||||
class BaselineFixture:
|
||||
"""The fake sysfs tree and LED class a baseline runs against. It has
|
||||
no tests of its own, so a class that mixes it in runs only its own."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
@@ -65,13 +68,6 @@ class BaselineTests(unittest.TestCase):
|
||||
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||
# only when the test asks (see _sync_leds)
|
||||
|
||||
def _led2(self, name, target, brightness):
|
||||
"""Both attributes of one button LED: the trigger's commanded
|
||||
level and the fade that follows it."""
|
||||
for a, v in (("target", target), ("brightness", brightness)):
|
||||
with open(self.leds + name + "/" + a, "w") as f:
|
||||
f.write(v)
|
||||
|
||||
def _sync_leds(self):
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
p = self.leds + name + "/target"
|
||||
@@ -88,6 +84,19 @@ class BaselineTests(unittest.TestCase):
|
||||
def bl(self):
|
||||
return baseline.Baseline(self.lines.append)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
|
||||
class BaselineTests(BaselineFixture, unittest.TestCase):
|
||||
def test_clean_machine_has_no_leftovers(self):
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
@@ -226,10 +235,6 @@ class BaselineTests(unittest.TestCase):
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
@@ -308,13 +313,6 @@ class BaselineTests(unittest.TestCase):
|
||||
|
||||
# -- the reference is taken after the controller applied its config -----
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
|
||||
self._probe_state()
|
||||
calls = {"n": 0}
|
||||
@@ -460,11 +458,7 @@ class BaselineTests(unittest.TestCase):
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ExtensionFreeTests(BaselineTests):
|
||||
class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
|
||||
"""A campaign's machine is extension-free: what a test made is removed,
|
||||
what is the operator's is reported and left alone."""
|
||||
|
||||
@@ -529,104 +523,6 @@ class ExtensionFreeTests(BaselineTests):
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||
|
||||
|
||||
class TransientNotLeftoverTests(BaselineTests):
|
||||
"""A leftover is what a run left behind, not the machine part-way
|
||||
through its own work. Found on the bench reference, where a laser
|
||||
test passed every check it makes and failed its hand-back on the
|
||||
post-job smoke clear."""
|
||||
|
||||
def test_a_fading_button_led_is_not_a_leftover(self):
|
||||
# the trigger fades brightness toward target: target 0 with
|
||||
# brightness still high is the fade from a level the machine ended
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
self._led2(name, target="0", brightness="900")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
|
||||
|
||||
def test_a_lit_button_led_is_a_leftover(self):
|
||||
# a target the run left standing is dirt, whatever the fade reads
|
||||
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
items = [x for x in left if x.item.startswith("leds/")]
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0].found, "1014")
|
||||
|
||||
|
||||
class CoolPublishTests(unittest.TestCase):
|
||||
"""The cooling engine publishes once a tick. A status read inside the
|
||||
tick after a run ended is the engine's last word on the run, not the
|
||||
machine as the run left it. Found on the bench reference: a diagnostic
|
||||
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
||||
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
||||
hold the engine's next tick cleared."""
|
||||
|
||||
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
||||
|
||||
def setUp(self):
|
||||
self.lines = []
|
||||
|
||||
def bl(self, statuses):
|
||||
"""A baseline whose /cool/status answers play in order (the last
|
||||
one repeats) and whose waits count reads, not seconds: three for
|
||||
the engine's next tick, as the real wait makes, and enough for the
|
||||
cooldown to let a slow script run out."""
|
||||
b = baseline.Baseline(self.lines.append)
|
||||
seq = list(statuses)
|
||||
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
||||
|
||||
def wait(what, pred, timeout):
|
||||
for i in range(3 if what == "cool publish" else 50):
|
||||
if pred():
|
||||
return float(i)
|
||||
return None
|
||||
b._wait = wait
|
||||
b.stood_down = []
|
||||
b.stand_down = b.stood_down.append
|
||||
return b
|
||||
|
||||
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
||||
{"phase": "smoke", "armed": False, "hold": False},
|
||||
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
||||
b._cool_side(left)
|
||||
self.assertEqual([x.item for x in left], ["cool"])
|
||||
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
||||
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
||||
self.assertEqual(len(b.stood_down), 1)
|
||||
|
||||
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
||||
left = []
|
||||
held = {"phase": "run", "armed": False, "hold": True}
|
||||
b = self.bl([held] * 10 + [self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual([x.item for x in left], ["cool"])
|
||||
self.assertEqual(left[0].action, "waited")
|
||||
self.assertEqual(b.stood_down, [])
|
||||
|
||||
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
||||
left = []
|
||||
self.bl([self.IDLE])._cool_side(left)
|
||||
b = baseline.Baseline(self.lines.append)
|
||||
b.fc_get = lambda path: (None, None)
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
|
||||
|
||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||
"""In cloud mode the cloud client configures the machine from the
|
||||
pulse header it is playing, the lens included: z_mode comes from
|
||||
@@ -651,35 +547,19 @@ class PositionDeadbandTests(unittest.TestCase):
|
||||
where motion.lid-cancel-home returned the head twice, each within
|
||||
0.04 mm, and failed the hand-back on the four steps left over."""
|
||||
|
||||
def test_a_few_steps_of_xy_are_the_quantization(self):
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
|
||||
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
|
||||
|
||||
def test_past_the_dead_band_is_a_leftover(self):
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||
|
||||
def test_the_dead_band_scales_with_the_counters(self):
|
||||
# the same step count is a quarter of the distance at x32
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
x32 = baseline.XY_STEPS_PER_MM_OF[32]
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
|
||||
|
||||
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||
|
||||
def test_an_unreadable_reading_is_never_quantization(self):
|
||||
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
||||
|
||||
|
||||
class BaselineModeTests(BaselineTests):
|
||||
class BaselineModeTests(BaselineFixture, unittest.TestCase):
|
||||
"""The baseline against a fake forgectrl: what the mode in force
|
||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||
tests run here (the inherited ones are skipped)."""
|
||||
owns, over the same fake sysfs tree."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -696,61 +576,6 @@ class BaselineModeTests(BaselineTests):
|
||||
self.fc.stop()
|
||||
super().tearDown()
|
||||
|
||||
def lamp_reads(self, *reads):
|
||||
"""The lid lamp reads `reads` in turn, then what its file holds:
|
||||
the smooth trigger's fade as the baseline polls it, one read per
|
||||
poll, so the script does not depend on the clock."""
|
||||
seq = list(reads)
|
||||
real = baseline.hw.sysfs_read
|
||||
|
||||
def read(attr, default=None):
|
||||
if attr == baseline.LID_LAMP_ATTR and seq:
|
||||
return seq.pop(0)
|
||||
return real(attr, default)
|
||||
baseline.hw.sysfs_read = read
|
||||
self.addCleanup(setattr, baseline.hw, "sysfs_read", real)
|
||||
|
||||
def lamp_polls(self):
|
||||
return max(2, int(round(baseline.LID_LAMP_SETTLE_S / baseline.LID_LAMP_POLL_S)))
|
||||
|
||||
def test_a_lamp_still_fading_to_its_idle_level_is_not_a_leftover(self):
|
||||
# The supervisor re-applied 236 a moment before the check: the lamp
|
||||
# reads 232 on its way there and then holds 236. Nothing to restore,
|
||||
# nothing to record.
|
||||
self._attr("pic/lid_led", "236")
|
||||
self.lamp_reads("232", "234", "236", "236")
|
||||
writes = []
|
||||
real_write = baseline.hw.sysfs_write
|
||||
baseline.hw.sysfs_write = lambda attr, value: (writes.append(attr), real_write(attr, value))
|
||||
self.addCleanup(setattr, baseline.hw, "sysfs_write", real_write)
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
self.assertNotIn(baseline.LID_LAMP_ATTR, writes)
|
||||
self.assertTrue(any("the fade toward a level just written" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_fade_that_only_passes_the_idle_level_is_a_leftover(self):
|
||||
# A lamp left fading to 77 passes 236 on its way down: one read at
|
||||
# the level is not the level held. It is restored and recorded at
|
||||
# the level it settled on.
|
||||
self.lamp_reads("240", "236", *(["77"] * (self.lamp_polls() - 1)))
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([(x.item, x.found, x.expected, x.action) for x in left],
|
||||
[("pic/lid_led", "77", "236", "restored")])
|
||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||
|
||||
def test_a_lamp_that_never_reaches_the_level_after_the_restore_says_so(self):
|
||||
# the write does not take: the restore is recorded as failed, with
|
||||
# the level the lamp holds
|
||||
self.lamp_reads(*(["77"] * (2 * self.lamp_polls() + 1)))
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([(x.item, x.action) for x in left], [("pic/lid_led", "failed: reads 77")])
|
||||
|
||||
def run(self, result=None):
|
||||
# only this class's own tests, not the base class's
|
||||
if self._testMethodName not in BaselineModeTests.__dict__:
|
||||
return
|
||||
return super().run(result)
|
||||
|
||||
def cloud(self):
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||
@@ -897,3 +722,7 @@ class BaselineModeTests(BaselineTests):
|
||||
cap["mode"] = "cloud"
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
|
||||
"""The bench registry against scripts/bench: every python tool is
|
||||
registered (or named as not-a-tool), every registered script exists,
|
||||
every argument spec builds a command line, every script compiles, and
|
||||
the shared helper resolves host and local mode as documented."""
|
||||
a ported tool builds a command line with its defaults, and every
|
||||
script compiles."""
|
||||
import glob
|
||||
import os
|
||||
import py_compile
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
@@ -56,78 +54,10 @@ class RegistryTests(unittest.TestCase):
|
||||
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
||||
|
||||
def test_flagged_args_build_option_pairs(self):
|
||||
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
|
||||
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
|
||||
self.assertTrue(ok, err)
|
||||
self.assertIn("--run", argv)
|
||||
self.assertEqual(argv[argv.index("--run") + 1], "live")
|
||||
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
|
||||
# an empty optional with an empty default is left off entirely
|
||||
self.assertNotIn("--auto", argv)
|
||||
# a choice outside the list is refused before anything runs
|
||||
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("run must be one of", err)
|
||||
|
||||
def test_unported_tools_are_host_only(self):
|
||||
# what stays unported is what cannot run on the machine at all
|
||||
for t in bench_mod.TOOLS:
|
||||
if not t["ported"]:
|
||||
self.assertEqual(t["where"], "host", t["id"])
|
||||
self.assertIn("not a bench-page tool", t["desc"], t["id"])
|
||||
|
||||
def test_scripts_compile(self):
|
||||
for s in self.scripts:
|
||||
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
||||
|
||||
|
||||
class GfbenchTests(unittest.TestCase):
|
||||
"""gfbench.py in a subprocess (module-level host resolution)."""
|
||||
|
||||
def run_snippet(self, code, env_extra):
|
||||
env = dict(os.environ)
|
||||
env.pop("GF_HOST", None)
|
||||
env.pop("FORGETEST_BENCH_DATA", None)
|
||||
env.update(env_extra)
|
||||
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||
return r.returncode, r.stdout.strip()
|
||||
|
||||
def test_host_mode(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
|
||||
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out, "192.0.2.7 False fakessh")
|
||||
|
||||
def test_local_when_named_local(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
|
||||
"print(gfbench.board('echo hi').strip())",
|
||||
{"GF_HOST": "127.0.0.1"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
|
||||
|
||||
def test_refuses_without_a_machine(self):
|
||||
if os.path.isdir("/sys/glowforge"):
|
||||
self.skipTest("running on the machine")
|
||||
rc, out = self.run_snippet("import gfbench", {})
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("GF_HOST", out)
|
||||
|
||||
def test_degc_and_data_dir(self):
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp(prefix="forgetest-bench-")
|
||||
rc, out = self.run_snippet(
|
||||
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
|
||||
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
|
||||
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
|
||||
self.assertEqual(rc, 0, out)
|
||||
val, nan, indir = out.split()
|
||||
# a plausible coolant reading (700 counts is room temperature territory)
|
||||
self.assertTrue(15.0 < float(val) < 35.0, val)
|
||||
self.assertEqual(nan, "True")
|
||||
self.assertEqual(indir, "True")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -14,7 +14,6 @@ session whose one motion the cooling engine stopped short, and a whole
|
||||
three-motion one. Runs against scratch logs and fakes for the controller."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@@ -130,30 +129,6 @@ class HandBackTests(unittest.TestCase):
|
||||
def expect_jog(self, dx, dy):
|
||||
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
||||
|
||||
# -- the travel ------------------------------------------------------
|
||||
def test_the_travel_is_the_sum_of_the_motions_ends(self):
|
||||
self.write(WHOLE)
|
||||
self.assertEqual(homeoff.session_travel(0), (4774 - 71 - 13096, 4600 - 144 - 7399))
|
||||
|
||||
def test_the_travel_starts_at_the_mark(self):
|
||||
self.write(WHOLE)
|
||||
mark = homeoff.session_mark()
|
||||
self.write(STOPPED)
|
||||
self.assertEqual(homeoff.session_travel(mark), (4390, 2682))
|
||||
|
||||
def test_the_cloud_clients_log_is_read_the_same_way(self):
|
||||
self.write(WHOLE.replace("gfhome[", "gfcloud["), self.cloudlog)
|
||||
self.assertEqual(homeoff.session_travel(0, cloud.GFCLOUD_LOG), (4774 - 71 - 13096, 4600 - 144 - 7399))
|
||||
self.assertEqual(homeoff.session_travel(0), (0, 0)) # gfhome's log is its own
|
||||
|
||||
def test_a_motion_with_no_end_leaves_the_travel_unknown(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
self.assertIsNone(homeoff.session_travel(0))
|
||||
|
||||
def test_a_log_rotated_under_the_run_leaves_the_travel_unknown(self):
|
||||
self.write(STOPPED)
|
||||
self.assertIsNone(homeoff.session_travel(os.path.getsize(self.log) + 1))
|
||||
|
||||
# -- the stopped-short judge --------------------------------------------
|
||||
def test_a_motion_stopped_short_fails_the_judge(self):
|
||||
self.write(STOPPED)
|
||||
@@ -162,12 +137,6 @@ class HandBackTests(unittest.TestCase):
|
||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||
self.assertEqual(len(ev["short_motions"]), 2)
|
||||
|
||||
def test_whole_motions_pass_the_judge(self):
|
||||
self.write(WHOLE)
|
||||
ev = {}
|
||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||
self.assertEqual(ev["short_motions"], [])
|
||||
|
||||
# -- after a camera home -------------------------------------------------
|
||||
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
|
||||
# the home zeroed the counters; the test's own jogs left a step or so
|
||||
@@ -228,21 +197,6 @@ class HandBackTests(unittest.TestCase):
|
||||
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
# -- the first failure wins --------------------------------------------
|
||||
def test_a_hand_back_that_fails_after_the_test_failed_is_logged_not_raised(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
ev = {"homed": True}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
try:
|
||||
raise Failed("the test's own failure")
|
||||
finally:
|
||||
homeoff.camera_home_return(self.ctx, ev, 0)
|
||||
self.assertEqual(str(cm.exception), "the test's own failure")
|
||||
self.assertIn("cannot be known", ev["return"]["failed"])
|
||||
self.assertTrue(any("the head is not back" in l for l in self.ctx.lines), self.ctx.lines)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertIsNone(sys.exc_info()[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3,22 +3,18 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||
gfcloud log: the real test functions run under the real runner Context,
|
||||
the operator prompts answered - and the standing notices of the machine
|
||||
actions acted on - by a script that also drives the replay (open the
|
||||
lid -> the machine's lid reads open; close it -> the service's re-hunt
|
||||
lines land). The log lines are the machine's own: bench excerpts
|
||||
(the lid-abort, button-wait, and lid-open-hunt runs) and, for the pause,
|
||||
the run loop's lines as its host test emits them on the print skeleton
|
||||
of the lid-abort excerpt.
|
||||
"""The cloud.* tests' false-PASS guards, against a fake forgectrl and a
|
||||
replayed gfcloud log: the real test functions run under the real runner
|
||||
Context, the operator prompts answered - and the standing notices of the
|
||||
machine actions acted on - by a script that also drives the replay. The
|
||||
log lines are the machine's own: the lid-abort bench excerpt, and for the
|
||||
pause, the run loop's lines as its host test emits them on the print
|
||||
skeleton of that excerpt.
|
||||
|
||||
What is proven here: the tests find the right lines in real log noise,
|
||||
reuse an existing cloud session and never switch back, restart the client
|
||||
for a fresh hunt, judge the print's own finish line (not another action's),
|
||||
wait the service's deferred moves out, and fail for the right reasons."""
|
||||
What is proven here: each test fails when the machine did the unsafe
|
||||
thing - a dark print with the latch open at the button wait, a lid stop
|
||||
that is polled rather than edge-driven, and a resume the kernel refused."""
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
@@ -28,7 +24,6 @@ import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cloud
|
||||
|
||||
@@ -41,22 +36,6 @@ def fixture(name):
|
||||
return f.read().decode().splitlines()
|
||||
|
||||
|
||||
# What the build under test logs around a print, in the machine's own format.
|
||||
WARM_UP_LINE = ("2026-08-17T09:44:02.100000+00:00 gfcloud[1522] INFO "
|
||||
"machine:_dwell warm up: holding 3.0 s")
|
||||
COOL_DOWN_LINE = ("2026-08-17T09:45:31.700000+00:00 gfcloud[1522] INFO "
|
||||
"machine:_dwell cool down: holding 10.0 s")
|
||||
PROGRESS_LINE = ("2026-08-17T09:44:05.200000+00:00 gfcloud[1522] INFO "
|
||||
"machine:__init__ print:progress: reporting against 47848 bytes every 30 s")
|
||||
LIMITS_LINE = ("2026-08-17T09:44:01.900000+00:00 gfcloud[1522] INFO "
|
||||
"machine:_motion job limits from the header: air_assist_min_rpm=116 "
|
||||
"coolant_max_c=33.0 coolant_min_c=5.0")
|
||||
# What the engine logs when the job's limits reach it (forgectrl's log).
|
||||
EFFECTIVE_LINE = ("2026-08-17T09:44:02.050000+00:00 forgectrl[410] INFO cool: effective limits: "
|
||||
"coolant ceiling 33.0 C (local 33.0, header 33.0) resume 31.0 C; floors coolant "
|
||||
"5.0 C, exhaust 0 rpm, intake 0 rpm, air assist 116 rpm (from the header, no gate yet)")
|
||||
|
||||
|
||||
def cut(lines, marker, count=1):
|
||||
"""(before, after) at the count-th line containing marker (the line
|
||||
itself opens `after`)."""
|
||||
@@ -196,8 +175,6 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
cloud.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
|
||||
FakeOffline.sent = []
|
||||
FakeOffline.on_send = None
|
||||
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
|
||||
self.client_limits = True # the client names its header limits
|
||||
cloud.QUIET_S = 0.4
|
||||
# A deadline, not a wait: a quiet machine is seen at once. The
|
||||
# replays land their lines from threads, so under a loaded host a
|
||||
@@ -287,635 +264,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertIn(needle, str(cm.exception))
|
||||
return cm.exception
|
||||
|
||||
# -- session detection -------------------------------------------------
|
||||
def test_offline_mark_is_not_a_live_session(self):
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[3100] INFO websocket:_on_open RX-EVENT: ready",
|
||||
OFFLINE_LINE])
|
||||
live, detail = cloud.session_live(3100)
|
||||
self.assertFalse(live)
|
||||
self.assertIn("offline", detail)
|
||||
self.assertTrue(cloud.client_offline(3100))
|
||||
|
||||
# -- the emulator ------------------------------------------------------------------
|
||||
EMU_PID = 4100
|
||||
EMU_START = ["2026-08-22T23:50:00.100000+00:00 gfcloud[4100] INFO ffmachine:build_emulator EMULATE: the "
|
||||
"emulator in this machine's identity, frames from /usr/share/gfutilities/emulator, no hardware",
|
||||
"2026-08-22T23:50:02.000000+00:00 gfcloud[4100] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-22T23:50:03.000000+00:00 gfcloud[4100] INFO websocket:ws_connect ESTABLISHED",
|
||||
"2026-08-22T23:50:05.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action hunt [1578300001]: "
|
||||
"finished with event \":completed\"",
|
||||
"2026-08-22T23:50:06.000000+00:00 gfcloud[4100] INFO websocket:img_upload COMPLETE",
|
||||
"2026-08-22T23:50:06.500000+00:00 gfcloud[4100] INFO basemachine:_finish_action lid_image [1578300002]: "
|
||||
"finished with event \":completed\""]
|
||||
EMU_PRINT = ["2026-08-22T23:51:00.000000+00:00 gfcloud[4100] INFO gfuiservice:run service action request: print (ready)",
|
||||
"2026-08-22T23:51:01.000000+00:00 gfcloud[4100] INFO websocket:fetch_motion pulse data is uncompressed, "
|
||||
"47848 byte body",
|
||||
"2026-08-22T23:51:02.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action print [1578300003]: "
|
||||
"finished with event \":completed\""]
|
||||
REAL_BACK = ["2026-08-22T23:52:01.000000+00:00 gfcloud[4200] INFO ffmachine:inhibit_connect_hunt NO-HUNT: the "
|
||||
"first settings report in the reconnect form; the service keeps its head position, no "
|
||||
"connect-time hunt",
|
||||
"2026-08-22T23:52:02.000000+00:00 gfcloud[4200] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-22T23:52:03.000000+00:00 gfcloud[4200] INFO websocket:ws_connect ESTABLISHED",
|
||||
"2026-08-22T23:52:03.500000+00:00 gfcloud[4200] INFO websocket:_on_open RX-EVENT: ready"]
|
||||
|
||||
def emulator_setup(self, print_lines=None, info=True):
|
||||
self.in_cloud(pid=2278)
|
||||
self.append(["2026-08-22T23:49:00.500000+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
starts = []
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
starts.append((os.path.exists(cloud.EMULATE_MARKER), os.path.exists(cloud.NOHUNT_MARKER)))
|
||||
if len(starts) == 1:
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=self.EMU_PID)
|
||||
self.append(self.EMU_START, delay=0.2)
|
||||
else:
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=4200)
|
||||
self.append(self.REAL_BACK, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
def at_print():
|
||||
os.makedirs(cloud.EMULATOR_WORK, exist_ok=True)
|
||||
if info:
|
||||
with open(os.path.join(cloud.EMULATOR_WORK, "2026-08-22_235101_print.info"), "w") as f:
|
||||
json.dump({"header_data": {"STfr": 10000, "MCsn": 0, "PDfm": 0}}, f)
|
||||
self.append(print_lines if print_lines is not None else self.EMU_PRINT, delay=0.1)
|
||||
return starts, {"Set up any small job": at_print}
|
||||
|
||||
def test_service_protocol_against_the_emulator(self):
|
||||
starts, hooks = self.emulator_setup()
|
||||
run = self.run_test(cloud.service_protocol, hooks=hooks, test_id="cloud.service-protocol")
|
||||
ev = run.evidence
|
||||
# the emulator under its marker with the hunt; the real client back without the hunt
|
||||
self.assertEqual(starts, [(True, False), (False, True)])
|
||||
self.assertFalse(os.path.exists(cloud.EMULATE_MARKER))
|
||||
self.assertFalse(os.path.exists(cloud.NOHUNT_MARKER))
|
||||
self.assertTrue(any("no hunt" in l for l in run.lines), run.lines[-5:])
|
||||
self.assertEqual([p for p, _ in self.fc.posts],
|
||||
["/controller/stop", "/controller/start", "/controller/stop", "/controller/start"])
|
||||
self.assertIn(":completed", ev["hunt"])
|
||||
self.assertIn(":completed", ev["print"])
|
||||
self.assertEqual(ev["images_uploaded"], 1)
|
||||
self.assertEqual(ev["header_stfr"], 10000)
|
||||
self.assertEqual(self.script.asked, ["Does the app show the print complete?"])
|
||||
self.assertTrue(any("PASS: session, hunt" in l for l in run.lines), run.lines[-5:])
|
||||
self.assertFalse(os.path.exists(cloud.EMULATOR_WORK))
|
||||
|
||||
def test_service_protocol_fails_when_the_print_does_not_complete_and_still_restores_the_client(self):
|
||||
failed = [l.replace(':completed"', ':failed"') for l in self.EMU_PRINT]
|
||||
starts, hooks = self.emulator_setup(print_lines=failed)
|
||||
self.assertFails(cloud.service_protocol, "did not complete", hooks=hooks)
|
||||
self.assertEqual(starts, [(True, False), (False, True)]) # the real client came back regardless
|
||||
|
||||
def test_markers_the_client_consumed_are_not_missed(self):
|
||||
# gfcloud takes a marker it read down itself; the suite's own removal is then a no-op
|
||||
starts, hooks = self.emulator_setup()
|
||||
on_post = self.fc.on_post
|
||||
|
||||
def consuming(path, form):
|
||||
r = on_post(path, form)
|
||||
if path == "/controller/start":
|
||||
for p in (cloud.EMULATE_MARKER, cloud.NOHUNT_MARKER):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
return r
|
||||
self.fc.on_post = consuming
|
||||
run = self.run_test(cloud.service_protocol, hooks=hooks, test_id="cloud.service-protocol")
|
||||
self.assertEqual(starts, [(True, False), (False, True)])
|
||||
self.assertTrue(any("PASS: session, hunt" in l for l in run.lines), run.lines[-5:])
|
||||
self.assertFalse(any("marker removed" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_the_emulators_session_is_not_the_machines(self):
|
||||
self.append(self.EMU_START + ["2026-08-22T23:50:07.000000+00:00 gfcloud[4100] INFO websocket:_on_open "
|
||||
"RX-EVENT: ready"])
|
||||
live, detail = cloud.session_live(4100)
|
||||
self.assertFalse(live)
|
||||
self.assertIn("emulator", detail)
|
||||
|
||||
# -- the offline entry ----------------------------------------------------------
|
||||
def test_enter_offline_reuses_a_running_offline_client(self):
|
||||
self.in_offline()
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_offline(ctx)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER))
|
||||
|
||||
def test_enter_offline_from_grbl_sets_the_marker_for_one_start_and_declares_the_mode(self):
|
||||
seen = {}
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode":
|
||||
seen["marker_at_start"] = os.path.exists(cloud.OFFLINE_MARKER)
|
||||
self.append([OFFLINE_LINE], delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "grbl", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_offline(ctx)
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/mode"])
|
||||
self.assertTrue(seen["marker_at_start"]) # the client started offline
|
||||
self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) # and the marker is gone again
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud")
|
||||
self.assertTrue(any("offline service up" in l for l in run.lines))
|
||||
|
||||
def test_enter_offline_restarts_a_service_client_in_cloud_mode(self):
|
||||
self.in_cloud(pid=2278)
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=3100)
|
||||
self.append([OFFLINE_LINE], delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_offline(ctx)
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
|
||||
def test_enter_cloud_restarts_an_offline_client_with_the_service(self):
|
||||
self.in_offline()
|
||||
with open(cloud.OFFLINE_MARKER, "w") as f:
|
||||
f.write("stale\n")
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, _close = cut(post, "_switch_event lid closed")
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) # taken down before the start
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
|
||||
self.append(pre + hunt_part, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_cloud(ctx)
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
self.assertTrue(any("offline service: restarting it with the service" in l for l in run.lines))
|
||||
|
||||
def test_offline_jobs_are_the_synthesized_square(self):
|
||||
from forgetest import puls
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
path = cloud.offline_job(ctx, "t.puls", seconds=5)
|
||||
tags, payload = puls.parse(open(path, "rb").read())
|
||||
self.assertEqual(tags["MCsn"], 0)
|
||||
self.assertEqual(tags["STfr"], 10000)
|
||||
self.assertFalse(any(b & 0x10 for b in payload if not b & 0x80)) # no LASER bit anywhere
|
||||
self.assertEqual(payload[0], 0x80) # a leading power byte of zero
|
||||
self.assertEqual(run.evidence["jobs"]["t.puls"]["compressed"], False)
|
||||
|
||||
def test_session_live_is_per_client(self):
|
||||
lines = fixture("huntlid")
|
||||
# the old client (1927) closed and left; the new one (2278) is ready
|
||||
self.append(lines)
|
||||
self.assertTrue(cloud.session_live(2278)[0])
|
||||
self.assertFalse(cloud.session_live(1927)[0])
|
||||
# an unknown pid falls back to the newest lines (ready)
|
||||
self.assertTrue(cloud.session_live(9999)[0])
|
||||
# a drop after the ready line: not live until it is ready again
|
||||
self.append(["2026-08-17T10:00:00.000000+00:00 gfcloud[2278] INFO websocket:_on_close RX-EVENT: closed (1006, )",
|
||||
"2026-08-17T10:00:00.100000+00:00 gfcloud[2278] INFO websocket:run RECONNECTING"])
|
||||
live, detail = cloud.session_live(2278)
|
||||
self.assertFalse(live)
|
||||
self.assertIn("RECONNECTING", detail)
|
||||
self.append(["2026-08-17T10:00:06.000000+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
self.assertTrue(cloud.session_live(2278)[0])
|
||||
|
||||
def test_enter_cloud_reuses_a_live_session_and_never_switches(self):
|
||||
self.in_cloud()
|
||||
self.append(fixture("huntlid"))
|
||||
run = self.run_test(lambda ctx: cloud.enter_cloud(ctx))
|
||||
self.assertTrue(any("reusing it" in l for l in run.lines), run.lines)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud")
|
||||
|
||||
NOHUNT_CLIENT = ["2026-08-22T23:52:01.000000+00:00 gfcloud[4200] INFO ffmachine:inhibit_connect_hunt NO-HUNT: the "
|
||||
"first settings report in the reconnect form; the service keeps its head position, no "
|
||||
"connect-time hunt",
|
||||
"2026-08-22T23:52:02.000000+00:00 gfcloud[4200] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-22T23:52:03.000000+00:00 gfcloud[4200] INFO websocket:ws_connect ESTABLISHED",
|
||||
"2026-08-22T23:52:03.500000+00:00 gfcloud[4200] INFO websocket:_on_open RX-EVENT: ready"]
|
||||
|
||||
def test_session_hunted_is_the_clients_own_hunt(self):
|
||||
self.append(fixture("huntlid"))
|
||||
self.assertTrue(cloud.session_hunted(2278)) # its connect-time hunt completed
|
||||
self.assertFalse(cloud.session_hunted(9999)) # no lines: unknown is not hunted
|
||||
self.append(self.NOHUNT_CLIENT)
|
||||
self.assertTrue(cloud.session_live(4200)[0])
|
||||
self.assertFalse(cloud.session_hunted(4200)) # live, never hunted
|
||||
self.append(self.EMU_START)
|
||||
self.assertFalse(cloud.session_hunted(4100)) # the emulator's hunt is not the machine's
|
||||
# a later re-hunt (the lid closed) makes a no-hunt client hunted
|
||||
self.append(["2026-08-22T23:55:00.000000+00:00 gfcloud[4200] INFO basemachine:_finish_action hunt [1578300020]: "
|
||||
"finished with event \":completed\""])
|
||||
self.assertTrue(cloud.session_hunted(4200))
|
||||
|
||||
def test_enter_cloud_restarts_a_live_session_that_never_hunted_with_the_hunt(self):
|
||||
# the real print must not ride a head position the service only believes
|
||||
self.in_cloud(pid=4200)
|
||||
self.append(fixture("huntlid") + self.NOHUNT_CLIENT)
|
||||
lines = fixture("huntlid")
|
||||
_, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
starts = []
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
starts.append(os.path.exists(cloud.NOHUNT_MARKER))
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
|
||||
self.append(post, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = self.run_test(lambda ctx: cloud.enter_cloud(ctx))
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
self.assertEqual(starts, [False]) # with the hunt: no marker
|
||||
self.assertTrue(any("never hunted" in l for l in run.lines), run.lines)
|
||||
self.assertTrue(any("connect-time hunt" in l and ":completed" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_enter_cloud_refuses_a_dead_session(self):
|
||||
self.in_cloud(pid=1927) # the client that shut down in the excerpt
|
||||
self.append(fixture("huntlid"))
|
||||
self.assertFails(cloud.enter_cloud, "no live service session")
|
||||
|
||||
def test_enter_cloud_switches_once_from_grbl_and_declares_it(self):
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
self.append(pre)
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode":
|
||||
self.append(post, delay=0.2) # the new client's session + hunt + moves
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = self.run_test(lambda ctx: cloud.enter_cloud(ctx))
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/mode"])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud") # declared: the baseline keeps it
|
||||
self.assertTrue(any("connect-time hunt" in l and ":completed" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_enter_cloud_waits_for_the_service_to_stop_moving(self):
|
||||
self.in_cloud()
|
||||
self.append(fixture("huntlid"))
|
||||
# a motion in flight (never idle within the timeout) fails, and says so
|
||||
self.fc.state["status"]["state"] = "running"
|
||||
cloud.QUIET_TIMEOUT_S = 3 # this one waits the deadline out; tearDown puts it back
|
||||
self.assertFails(cloud.enter_cloud, "still running service moves")
|
||||
|
||||
def test_the_quiet_waits_out_the_service_thinking_after_a_lid_image(self):
|
||||
# Between a lid image and the service's next move the log is silent
|
||||
# while the service works on the image (the re-hunt on the bench
|
||||
# reference, 2026-09-25). The quiet counts from the image's own
|
||||
# lines, so it is not declared in that gap: it comes after the move.
|
||||
cloud.QUIET_S = 1.0
|
||||
offset = cloud.log_size(self.log)
|
||||
p = "2026-09-25T16:43:%s+00:00 gfcloud[12821] INFO "
|
||||
self.append([p % "20.103479" + "gfuiservice:run service action request: motion (ready)",
|
||||
p % "20.105376" + "machine:_motion start motion",
|
||||
p % "22.617720" + "machine:_motion_locked end positions (12120, 7402, 0)",
|
||||
p % "22.618542" + "machine:_motion end motion",
|
||||
p % "22.619218" + "basemachine:_finish_action motion [1588864337]: finished with event "
|
||||
"\":completed\""])
|
||||
self.append([p % "23.249589" + "gfuiservice:run service action request: lid_image (ready)",
|
||||
p % "24.686536" + "websocket:img_upload COMPLETE"], delay=0.8)
|
||||
self.append([p % "29.667978" + "gfuiservice:run service action request: motion (ready)",
|
||||
p % "29.671663" + "machine:_motion start motion"], delay=1.6)
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
self.assertTrue(cloud.wait_quiet(ctx, offset))
|
||||
self.assertIn("service action request: motion", cloud.log_lines_since(self.log, offset)[-2])
|
||||
|
||||
# -- the hunt with the lid open ------------------------------------------
|
||||
# -- the mode switch: hunt with the lid open, then $H -----------------------
|
||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||
# A homing session's motions as gfhome logs them (the bench reference,
|
||||
# 2026-09-25): a small correction, then into the camera home.
|
||||
HOME_SESSION = [
|
||||
"2026-09-25T16:43:45.374951+00:00 gfhome[12982] INFO machine:_motion start motion",
|
||||
"2026-09-25T16:43:45.853976+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13, -13, 0)",
|
||||
"2026-09-25T16:43:45.854819+00:00 gfhome[12982] INFO machine:_motion end motion",
|
||||
"2026-09-25T16:43:45.856076+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864529]: "
|
||||
"finished with event \":completed\"",
|
||||
"2026-09-25T16:43:51.600734+00:00 gfhome[12982] INFO machine:_motion start motion",
|
||||
"2026-09-25T16:43:53.605402+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13095, -7399, 0)",
|
||||
"2026-09-25T16:43:53.606171+00:00 gfhome[12982] INFO machine:_motion end motion",
|
||||
"2026-09-25T16:43:53.606916+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864567]: "
|
||||
"finished with event \":completed\""]
|
||||
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False, home_lines=None):
|
||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||
the switch to cloud - the client's start at once, its session and
|
||||
hunt once the lid reads open, as on the bench where the lid opens
|
||||
behind the controller's start and ahead of the hunt (the hunt
|
||||
reads as a run to the cooling engine while it lasts) - the re-hunt
|
||||
on the lid close, and gfhome finishing the homing after $H. With
|
||||
lid_late the hunt is requested before the lid opens: the race the
|
||||
test must call. The step counters are the machine's: a controller
|
||||
start zeroes them and removes the homing anchor, the home zeroes
|
||||
them and writes it, and a jog moves them by what it asked."""
|
||||
self.grbl = helpers.FakeGrbl().start()
|
||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||
self._attr("pic/lid_led", "236")
|
||||
self.fc.state["settings"]["homing_mode"] = "gfcloud"
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
self.addCleanup(setattr, baseline, "ANCHOR_PATH", baseline.ANCHOR_PATH)
|
||||
baseline.ANCHOR_PATH = anchor
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, close_part = cut(post, "_switch_event lid closed")
|
||||
if hunt_lines is not None:
|
||||
hunt_part = hunt_lines(hunt_part)
|
||||
fc = self.fc
|
||||
|
||||
def controller_start():
|
||||
self._pos(0, 0, 3)
|
||||
if os.path.exists(anchor):
|
||||
os.remove(anchor)
|
||||
|
||||
def jog(line):
|
||||
if line.startswith("$J=G91"):
|
||||
words = dict((w[0], float(w[1:])) for w in line[3:].split() if w[0] in "XY")
|
||||
with open(self.sysfs + "cnc/position", "rb") as f:
|
||||
x, y, z = struct.unpack("<3i", f.read(12))
|
||||
self._pos(x + round(words.get("X", 0.0) * baseline.XY_STEPS_PER_MM),
|
||||
y + round(words.get("Y", 0.0) * baseline.XY_STEPS_PER_MM), z)
|
||||
self.grbl.on_command = jog
|
||||
|
||||
def hunt():
|
||||
self.append(hunt_part, delay=0.0)
|
||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode" and form.get("controller") == "cloud":
|
||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||
|
||||
def land():
|
||||
self.append(pre, delay=0.0)
|
||||
if lid_late:
|
||||
return
|
||||
t0 = time.time()
|
||||
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.3)
|
||||
hunt()
|
||||
threading.Thread(target=land, daemon=True).start()
|
||||
elif path == "/mode" and form.get("controller") == "grbl":
|
||||
controller_start()
|
||||
self.grbl.state = "Idle"
|
||||
elif path == "/controller/stop":
|
||||
fc.state["mode"] = dict(fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
controller_start()
|
||||
fc.state["mode"] = dict(fc.state["mode"], controller="running", pid=4300, motion="verified")
|
||||
fc.state["status"]["homed"] = False
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
def lid_opened():
|
||||
if lid_late:
|
||||
self.append(hunt_part, delay=0.0)
|
||||
self.lid(False)
|
||||
|
||||
def lid_closed():
|
||||
self.lid(True)
|
||||
self.append(close_part, delay=0.2)
|
||||
|
||||
def homing():
|
||||
self.grbl.state = "Home"
|
||||
with open(self.homelog, "ab") as f:
|
||||
f.write(("\n".join(home_lines or self.HOME_SESSION) + "\n").encode())
|
||||
time.sleep(0.4)
|
||||
self._pos(0, 0, 3)
|
||||
open(anchor, "w").close()
|
||||
self.grbl.state = "Idle"
|
||||
self.fc.state["status"]["homed"] = True
|
||||
if home_complete:
|
||||
with open(self.homelog, "ab") as f:
|
||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||
b"(service quiet 8s, 3 motion windows)\n")
|
||||
self.home_hook = homing
|
||||
return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
|
||||
|
||||
def wait_home_command(self):
|
||||
while "$H" not in self.grbl.sent:
|
||||
time.sleep(0.02)
|
||||
self.home_hook()
|
||||
|
||||
def test_mode_switch_round_trip_with_the_hunt_lid_open_and_the_homing(self):
|
||||
hooks = self.mode_switch_setup()
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||
ev = run.evidence
|
||||
posts = [(p, f.get("controller")) for p, f in self.fc.posts if p == "/mode"]
|
||||
self.assertEqual(posts, [("/mode", "cloud"), ("/mode", "grbl")])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||
self.assertIn(":completed", ev["hunt_line"])
|
||||
self.assertEqual(ev["refusals_before_hunt_end"], 0)
|
||||
self.assertTrue(ev["lens_homed"])
|
||||
self.assertEqual(ev["motions_after_lid_close"], 3)
|
||||
self.assertTrue(all(c.get("verdict") != "AIRFLOW" for c in ev["hunt_gates"]))
|
||||
self.assertTrue(ev["homed"])
|
||||
self.assertIn("3 motion windows", ev["gfhome_complete"])
|
||||
self.assertIn("$H", self.grbl.sent)
|
||||
# the lid was a machine action, not a prompt: two notices, no questions
|
||||
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
|
||||
self.assertEqual(self.script.asked, [])
|
||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||
# The head goes back where the test found it, each stretch by its
|
||||
# client's record: the re-hunt of the excerpt ends 2/7 steps from
|
||||
# where the cloud mode began, and the homing went into the corner.
|
||||
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (2, 7))
|
||||
self.assertEqual(tuple(ev["return"]["session_travel_steps"]), (-13108, -7412))
|
||||
spm = baseline.XY_STEPS_PER_MM
|
||||
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")],
|
||||
["$J=G91 G21 X%.3f Y%.3f F2400" % (-2 / spm, -7 / spm),
|
||||
"$J=G91 G21 X%.3f Y%.3f F2400" % (13108 / spm, 7412 / spm)])
|
||||
# a start after each jog, and the camera home dropped before the second
|
||||
self.assertEqual([p for p, f in self.fc.posts if p.startswith("/controller/")],
|
||||
["/controller/stop", "/controller/start"] * 3)
|
||||
with open(self.sysfs + "cnc/position", "rb") as f:
|
||||
self.assertEqual(struct.unpack("<3i", f.read(12))[:2], (0, 0))
|
||||
|
||||
def test_mode_switch_a_hand_back_that_fails_too_does_not_hide_the_failure(self):
|
||||
# gfhome never said it homed, and its last motion has no end on
|
||||
# record: the travel is unknown, the head is not moved, and the
|
||||
# failure reported is the test's own
|
||||
hooks = self.mode_switch_setup(home_complete=False, home_lines=self.HOME_SESSION[:5])
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
|
||||
run = self.script.run
|
||||
self.assertTrue(any("the head is not back" in l for l in run.lines), run.lines[-6:])
|
||||
self.assertIn("cannot be known", run.evidence["return"]["failed"])
|
||||
self.assertEqual(len([l for l in self.grbl.sent if l.startswith("$J=")]), 1) # the cloud stretch's
|
||||
|
||||
def test_mode_switch_fails_when_the_travel_in_cloud_mode_cannot_be_known(self):
|
||||
# the hunt's motion has no end on record (a client killed inside it)
|
||||
hooks = self.mode_switch_setup(
|
||||
hunt_lines=lambda part: [l for l in part if not l.endswith("machine:_motion end motion")])
|
||||
self.assertFails(cloud.mode_switch, "the travel in cloud mode cannot be known", hooks=hooks)
|
||||
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")], [])
|
||||
|
||||
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
|
||||
# No controller starts with the enclosure open: the switch is made
|
||||
# lid closed, the lid opens once the controller is up, and the
|
||||
# hunt's request lands after that - the order the test records.
|
||||
hooks = self.mode_switch_setup()
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||
self.assertFalse(run.evidence["hunt_before_lid_open"])
|
||||
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
|
||||
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
|
||||
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
|
||||
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
|
||||
self.assertLess(i_post, i_open)
|
||||
|
||||
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
|
||||
hooks = self.mode_switch_setup()
|
||||
self.lid(False)
|
||||
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
|
||||
hooks = self.mode_switch_setup(lid_late=True)
|
||||
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||
def refused(hunt_part):
|
||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||
return hunt_part[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move "
|
||||
"lid opened, unsafe to move"] + hunt_part[i:]
|
||||
hooks = self.mode_switch_setup(hunt_lines=refused)
|
||||
self.assertFails(cloud.mode_switch, "refused for the lid", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_skipped_the_lens(self):
|
||||
hooks = self.mode_switch_setup(hunt_lines=lambda part: [l for l in part if "z homing cycle" not in l])
|
||||
self.assertFails(cloud.mode_switch, "did not home the lens", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_gfhome_never_saw_the_head_move(self):
|
||||
hooks = self.mode_switch_setup(home_complete=False)
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
|
||||
|
||||
def test_mode_switch_needs_no_setting_from_the_operator(self):
|
||||
# The test sets the gfcloud homing itself and restores it: no precheck
|
||||
# names a setting the operator should change first.
|
||||
from forgetest import catalog
|
||||
t = catalog.load_suite()["cloud.mode-switch"]
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertNotIn("homing_mode_is_gfcloud", dir(cloud))
|
||||
|
||||
# -- pause / resume ---------------------------------------------------------
|
||||
def replay_print(self, name, at_run, at_end, tail_delay=0.3):
|
||||
"""Split a print excerpt into: up to the run's RUNNING line
|
||||
(lands when the operator says Print is done), the block landing at
|
||||
the mid-run prompt, and the rest."""
|
||||
lines = fixture(name)
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
run_pre, rest = cut(rest, "current state: MachineState.RUNNING") # the PRINT's run
|
||||
# The excerpt was captured before the machine held for a warm-up and
|
||||
# a rest, and before it reported a print's progress; the replay
|
||||
# carries those lines where it emits them now, rather than editing
|
||||
# what the machine actually said that day.
|
||||
run_pre = run_pre + ([LIMITS_LINE] if self.client_limits else []) + [WARM_UP_LINE, PROGRESS_LINE]
|
||||
pre, rest = pre + run_pre + [rest[0]], rest[1:]
|
||||
mid, tail = cut(rest, at_end)
|
||||
tail = tail + [COOL_DOWN_LINE]
|
||||
|
||||
def at_done():
|
||||
self.append(pre, delay=0.1)
|
||||
if self.engine_line:
|
||||
with open(self.fclog, "ab") as f:
|
||||
f.write((self.engine_line + "\n").encode())
|
||||
return {"Click Done here": at_done,
|
||||
at_run: lambda: (self.append(mid, delay=0.05), self.append(tail, delay=tail_delay))}
|
||||
|
||||
def test_pause_resume_passes_on_the_machines_lines(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.000000+00:00 gfcloud[1522] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready",
|
||||
"2026-08-17T09:41:00.900000+00:00 gfcloud[1522] INFO websocket:ws_connect ESTABLISHED"])
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
run = self.run_test(cloud.pause_resume, hooks=hooks, test_id="cloud.pause-resume")
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["log"], {"button pressed mid-run; pausing": True, "paused at": True,
|
||||
"button pressed while paused": True, "resuming (laser lead": True})
|
||||
self.assertTrue(ev["armed_after_resume"])
|
||||
self.assertIn(":completed", ev["log_end"]["print finished"])
|
||||
self.assertTrue(ev["log_end"]["return home complete"])
|
||||
self.assertEqual(ev["relock_or_cancel_lines"], 0)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: button pause/resume" in l for l in run.lines))
|
||||
# the post-print hunt was waited out
|
||||
self.assertTrue(any("machine is quiet" in l for l in run.lines))
|
||||
# the job's envelope passed through: the client's line and the engine's
|
||||
self.assertEqual(ev["header_limits"],
|
||||
"air_assist_min_rpm=116 coolant_max_c=33.0 coolant_min_c=5.0")
|
||||
self.assertIn("header 33.0", ev["effective_limits"][0]) # the line with the header
|
||||
self.assertTrue(all("effective limits: coolant ceiling 33.0 C" in ln for ln in ev["effective_limits"]))
|
||||
|
||||
def test_pause_resume_fails_when_the_client_names_no_header_limits(self):
|
||||
self.client_limits = False
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
self.assertFails(cloud.pause_resume, "named no job limits", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_engine_resolves_against_no_header(self):
|
||||
self.engine_line = EFFECTIVE_LINE.replace("header 33.0", "header none 0.0")
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
self.assertFails(cloud.pause_resume, "never resolved an effective ceiling", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_print_is_cancelled_instead(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = fixture("pause")
|
||||
lines = [l.replace('print [1576550507]: finished with event ":completed"',
|
||||
'print [1576550507]: finished with event ":cancelled"') for l in lines]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "did not complete after the resume", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_without_the_retraced_restart(self):
|
||||
"""The second press was seen but the retraced restart never logged
|
||||
(the app's resume is its own line now): the failure names that."""
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = [l for l in fixture("pause") if "resuming (laser lead" not in l]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "no retraced restart logged", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
@@ -947,19 +296,6 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
cloud.wait_log = saved
|
||||
cloud.PRESS_TIMEOUT_S = press
|
||||
|
||||
def test_pause_resume_fails_without_the_pause_line(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = [l for l in fixture("pause") if "button pressed mid-run" not in l]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "did not pause the run", hooks=hooks)
|
||||
|
||||
# -- the lid/interlock abort and the button-wait tests, on their excerpts ------
|
||||
# -- the merged lid + interlock abort test -------------------------------
|
||||
def abort_parts(self):
|
||||
"""The lid-abort excerpt split for the merged test: the print
|
||||
@@ -996,54 +332,6 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
"on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
|
||||
"Restore the remote-interlock loop": restore}
|
||||
|
||||
def test_lid_interlock_abort_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
prints = []
|
||||
run = self.run_test(cloud.lid_interlock_abort,
|
||||
hooks=self.abort_hooks(*self.abort_parts(), prints),
|
||||
test_id="cloud.lid-interlock-abort")
|
||||
ev = run.evidence
|
||||
self.assertEqual(prints, [9001, 9002]) # two prints, handed over the socket
|
||||
self.assertTrue(FakeOffline.sent[0]["motion_url"].endswith("abort.puls"))
|
||||
self.assertEqual(ev["jobs"]["abort.puls"]["compressed"], False)
|
||||
# print 1: the lid
|
||||
self.assertLess(ev["edge_to_stop_ms"], 60)
|
||||
self.assertIn(":cancelled", ev["lid_log"]["print finished"])
|
||||
self.assertEqual(ev["lid_counters_after_park"], [0, 0, 3])
|
||||
self.assertTrue(ev["lid_latch_locked"])
|
||||
self.assertFalse(ev["lid_armed_after"])
|
||||
# print 2: the interlock, with the lid opened during the park
|
||||
self.assertFalse(ev["interlock_ok_after_pull"])
|
||||
self.assertIn(":cancelled", ev["interlock_log"]["print finished"])
|
||||
self.assertTrue(ev["interlock_log"]["return home complete"])
|
||||
self.assertEqual(ev["switches_at_return"], {"lid": False, "interlock_ok": False})
|
||||
self.assertEqual(ev["interlock_counters_after_park"], [0, 0, 3])
|
||||
self.assertTrue(ev["interlock_latch_locked"])
|
||||
self.assertEqual(ev["restored"], {"lid": True, "interlock_ok": True})
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: lid open" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_lid_interlock_abort_refuses_when_the_loop_is_already_open(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = False
|
||||
self.assertFails(cloud.lid_interlock_abort, "already reads open")
|
||||
|
||||
def test_lid_interlock_abort_fails_when_the_park_stops_at_the_lid(self):
|
||||
# the regression this guards: a park an open lid can interrupt
|
||||
self.in_offline()
|
||||
pre, lid_tail, ilk_stop, ilk_park = self.abort_parts()
|
||||
ilk_park = [l for l in ilk_park if "return home complete" not in l]
|
||||
saved = cloud.wait_log
|
||||
|
||||
def fast_wait_log(ctx, offset, needles, timeout, poll=0.5):
|
||||
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
|
||||
cloud.wait_log = fast_wait_log
|
||||
try:
|
||||
self.assertFails(cloud.lid_interlock_abort, "did not run to completion",
|
||||
hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, []))
|
||||
finally:
|
||||
cloud.wait_log = saved
|
||||
|
||||
def test_lid_interlock_abort_fails_when_the_lid_stop_is_not_edge_driven(self):
|
||||
# a polled stop (the pre-parity behavior) shows up as a long edge->stop gap
|
||||
self.in_offline()
|
||||
@@ -1072,17 +360,6 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||
self.assertFails(dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
def test_print_finish_is_the_prints_not_another_actions(self):
|
||||
# a motion that completes before the print must not satisfy the print's finish
|
||||
lines = fixture("lidabort")
|
||||
i = cloud.action_finish_index(lines, "print")
|
||||
j = cloud.action_finish_index(lines, "motion")
|
||||
self.assertIsNotNone(i)
|
||||
self.assertIsNotNone(j)
|
||||
self.assertLess(j, i)
|
||||
self.assertIn(":cancelled", lines[i])
|
||||
self.assertIn(":completed", lines[j])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3,774 +3,16 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||
under the real runner Context against a scripted machine: a fake
|
||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
|
||||
a fake Grbl port that answers '?' and every command.
|
||||
|
||||
The bench case that motivated this: the test started one second after
|
||||
the engine of a previous test went idle, took the tachs still coasting
|
||||
at the cooldown level as its idle reference, and then waited for the
|
||||
fans to come back UP to it. The idle reference now needs the engine
|
||||
idle, the idle duty applied, and tachs that have stopped changing; the
|
||||
pass condition is at-or-below that level, and every sample is logged.
|
||||
"""
|
||||
"""cooling.flow-under-load reads the engine's flow verdict from what the
|
||||
forgectrl log gained since the job began: only the text after the offset,
|
||||
never an older verdict before it, and the whole of a log rotated under
|
||||
the test."""
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cooling, motion
|
||||
|
||||
|
||||
class EngineLog:
|
||||
"""The engine's log lines, as the tests read them: appended to a scratch
|
||||
forgectrl log (a check reads the lines written after its mark) and to the
|
||||
fake /logs/tail."""
|
||||
|
||||
def log_setup(self):
|
||||
self.logdir = tempfile.mkdtemp(prefix="forgetest-cool-")
|
||||
self.saved_log = motion.FORGECTRL_LOG
|
||||
motion.FORGECTRL_LOG = os.path.join(self.logdir, "forgectrl.log")
|
||||
open(motion.FORGECTRL_LOG, "w").close()
|
||||
|
||||
def log_teardown(self):
|
||||
motion.FORGECTRL_LOG = self.saved_log
|
||||
shutil.rmtree(self.logdir, ignore_errors=True)
|
||||
|
||||
def emit(self, text):
|
||||
self.fc.state.setdefault("logs_tail", {"text": ""})["text"] += text
|
||||
with open(motion.FORGECTRL_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
class FakeGrbl:
|
||||
"""Answers '?' with an Idle report and every line with ok."""
|
||||
|
||||
def __init__(self):
|
||||
self.sock = socket.socket()
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.listen(2)
|
||||
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
|
||||
self.commands = []
|
||||
self.on_command = None
|
||||
self._stop = False
|
||||
threading.Thread(target=self._serve, daemon=True).start()
|
||||
|
||||
def _serve(self):
|
||||
self.sock.settimeout(0.2)
|
||||
while not self._stop:
|
||||
try:
|
||||
c, _ = self.sock.accept()
|
||||
except OSError:
|
||||
continue
|
||||
threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
||||
|
||||
def _client(self, c):
|
||||
c.settimeout(0.1)
|
||||
buf = b""
|
||||
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
|
||||
while not self._stop:
|
||||
try:
|
||||
d = c.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
if not d:
|
||||
break
|
||||
for ch in d:
|
||||
if ch == 0x3F: # '?'
|
||||
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
|
||||
elif ch in (0x18, 0x85):
|
||||
pass
|
||||
else:
|
||||
buf += bytes([ch])
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
line = line.decode().strip()
|
||||
if line:
|
||||
self.commands.append(line)
|
||||
if self.on_command:
|
||||
self.on_command(line)
|
||||
c.sendall(b"ok\r\n")
|
||||
c.close()
|
||||
|
||||
def close(self):
|
||||
self._stop = True
|
||||
self.sock.close()
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
class FansQuietTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
os.makedirs(self.sysfs + "thermal")
|
||||
os.makedirs(self.sysfs + "cnc")
|
||||
with open(self.sysfs + "cnc/state", "w") as f:
|
||||
f.write("idle")
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
||||
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
|
||||
cooling.SAMPLE_S = 0.1
|
||||
cooling.IDLE_REF_TIMEOUT_S = 3
|
||||
cooling.COOLDOWN_TIMEOUT_S = 4
|
||||
cooling.RESTART_IDLE_TIMEOUT_S = 1
|
||||
# The daemon restart of the last phase, scripted: the init script
|
||||
# is a stub, and the engine that comes up applies `after_start`
|
||||
# (the idle duties by default; a daemon that keeps the busy-start
|
||||
# cooldown airflow applies those instead).
|
||||
self.initd_calls = []
|
||||
self.after_start = (0, 0)
|
||||
|
||||
def initd(service, action, timeout=60):
|
||||
self.initd_calls.append((service, action))
|
||||
if action == "start":
|
||||
self.duty(*self.after_start)
|
||||
return 0, ""
|
||||
cooling.hw.initd = initd
|
||||
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
self.duty(0, 0)
|
||||
self.fans(0, 736, 733)
|
||||
|
||||
def tearDown(self):
|
||||
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
||||
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# -- the machine -------------------------------------------------------
|
||||
def duty(self, exhaust, intake):
|
||||
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
|
||||
with open(self.sysfs + attr, "w") as f:
|
||||
f.write(str(v))
|
||||
|
||||
def fans(self, exhaust, i1, i2, air=2080):
|
||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
|
||||
|
||||
def run_engine(self):
|
||||
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
|
||||
tachs coast down (a few samples of spin-down), like forgectrl's
|
||||
engine on the bench."""
|
||||
def on_command(line):
|
||||
if line == "M8":
|
||||
self.duty(65535, 43278)
|
||||
self.fc.state["cool"]["phase"] = "run"
|
||||
self.fans(6753, 3212, 3328, air=10997)
|
||||
elif line == "M9":
|
||||
def cooldown():
|
||||
self.fc.state["cool"]["phase"] = "smoke"
|
||||
time.sleep(0.3)
|
||||
self.fc.state["cool"]["phase"] = "idle"
|
||||
self.duty(0, 0)
|
||||
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
|
||||
self.fans(ex, i1, i1 + 5)
|
||||
time.sleep(0.25)
|
||||
threading.Thread(target=cooldown, daemon=True).start()
|
||||
self.grbl.on_command = on_command
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||
cooling.fans_quiet(ctx)
|
||||
return run
|
||||
|
||||
# -- cases ------------------------------------------------------------------
|
||||
def test_idle_machine_passes_and_logs_every_sample(self):
|
||||
self.run_engine()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["before"]["exhaust"], 0)
|
||||
self.assertIsNotNone(run.evidence["settle_s"])
|
||||
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
|
||||
self.assertIn("M8", self.grbl.commands)
|
||||
self.assertIn("M9", self.grbl.commands)
|
||||
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
|
||||
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
|
||||
|
||||
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
|
||||
"""The bench case: the previous test's fans are still coasting when
|
||||
this one starts. The reference waits for them to settle, and the
|
||||
run passes instead of waiting for the fans to come back up."""
|
||||
self.run_engine()
|
||||
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
|
||||
|
||||
def coast():
|
||||
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
|
||||
time.sleep(0.12)
|
||||
self.fans(ex, i1, i1 + 5)
|
||||
threading.Thread(target=coast, daemon=True).start()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
|
||||
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
|
||||
self.assertIsNotNone(run.evidence["settle_s"])
|
||||
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
|
||||
|
||||
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
|
||||
self.run_engine()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.finished, None)
|
||||
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
|
||||
with open(self.sysfs + "cnc/disable") as f:
|
||||
self.assertEqual(f.read(), "1")
|
||||
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
|
||||
self.assertIsNotNone(run.evidence["restart_idle_s"])
|
||||
|
||||
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
|
||||
# The bench case: after a takeover's restart the exhaust ran at
|
||||
# cooldown duty on an idle machine for good.
|
||||
self.run_engine()
|
||||
self.after_start = (32768, 21639)
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("kept the busy-start airflow", str(cm.exception))
|
||||
self.assertIn("32768", str(cm.exception))
|
||||
|
||||
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
|
||||
def on_command(line):
|
||||
if line == "M8":
|
||||
self.duty(65535, 43278)
|
||||
self.fc.state["cool"]["phase"] = "run"
|
||||
self.fans(6753, 3212, 3328)
|
||||
# M9 ignored: the run profile stays on
|
||||
self.grbl.on_command = on_command
|
||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||
with self.assertRaises(Failed) as cm:
|
||||
cooling.fans_quiet(ctx)
|
||||
self.assertIn("did not return to the idle profile", str(cm.exception))
|
||||
self.assertIn("idle reference", str(cm.exception))
|
||||
self.assertIn("65535", str(cm.exception))
|
||||
|
||||
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
|
||||
def churn():
|
||||
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
|
||||
i = 0
|
||||
while not self.grbl._stop:
|
||||
v = seq[i % len(seq)]
|
||||
i += 1
|
||||
self.fans(v, v, v)
|
||||
time.sleep(0.03)
|
||||
threading.Thread(target=churn, daemon=True).start()
|
||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||
with self.assertRaises(Failed) as cm:
|
||||
cooling.fans_quiet(ctx)
|
||||
self.assertIn("never settled to an idle reference", str(cm.exception))
|
||||
self.assertEqual(self.grbl.commands, []) # nothing was jogged
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class GateOffTests(EngineLog, unittest.TestCase):
|
||||
"""cooling.gate-off against a scripted engine: the fake forgectrl
|
||||
re-reads the ceiling at every M8 (as the engine reloads its tunables
|
||||
at run start), trips OVERTEMP when the coolant is over it, skips the
|
||||
gate and reports gates_off when the ceiling sits at its top, and
|
||||
writes the run-start line the test looks for."""
|
||||
|
||||
TOP, BOTTOM = 60.0, 5.0
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.log_setup()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 3
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
|
||||
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
|
||||
"soc_throttle": 0},
|
||||
gates_off=[])
|
||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||
"gates_off": []}
|
||||
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
|
||||
self.log_line = True # the engine writes its run-start line
|
||||
self.temps_line = True # the engine writes its run-end temperature line
|
||||
self.report_off = True # the engine reports the off gate
|
||||
self.trips = True # the engine trips a low ceiling
|
||||
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
|
||||
self._describe()
|
||||
self.grbl.on_command = self._engine
|
||||
self.fc.on_post = self._on_post
|
||||
|
||||
def tearDown(self):
|
||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
self.log_teardown()
|
||||
|
||||
# -- the scripted machine --------------------------------------------------
|
||||
def ceiling(self):
|
||||
v = self.fc.state["settings"].get("cool_temp_max") or ""
|
||||
return float(v) if v else 33.0
|
||||
|
||||
def _describe(self):
|
||||
"""/settings carries the gates table the way forgectrl's gates.c
|
||||
publishes it, classified from the stored value."""
|
||||
v = self.ceiling()
|
||||
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
|
||||
self.fc.state["settings"]["gates"] = {
|
||||
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
|
||||
"band": [25, 38], "off": "high", "value": v, "state": state}}
|
||||
|
||||
def _on_post(self, path, form):
|
||||
"""The real settings reply re-classifies the gates from the new values."""
|
||||
if path != "/settings":
|
||||
return None
|
||||
self.fc.state["settings"].update(form)
|
||||
self._describe()
|
||||
return (200, self.fc.state["settings"])
|
||||
|
||||
def _engine(self, line):
|
||||
"""M8 opens a run session: the engine re-reads the ceiling and
|
||||
ticks the gate. M9 ends it a report period later (the phase
|
||||
leaves run); a hold taken against the old ceiling stands until
|
||||
the next session re-reads."""
|
||||
self._describe()
|
||||
cool = self.fc.state["cool"]
|
||||
if line == "M9":
|
||||
def end():
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "smoke"
|
||||
if self.temps_line:
|
||||
self.emit(
|
||||
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
|
||||
"soc 42.8..47.1 C, supply raw 587..592\n")
|
||||
time.sleep(0.2)
|
||||
cool["phase"] = "idle"
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
self.sessions += 1
|
||||
cool["phase"] = "run"
|
||||
v = self.ceiling()
|
||||
off = v >= self.TOP
|
||||
if off and self.log_line:
|
||||
self.emit(
|
||||
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
||||
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
||||
gates_off = ["coolant_max"] if off and self.report_off else []
|
||||
if not off and self.trips and 22.3 > v:
|
||||
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
|
||||
else:
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
|
||||
self.fc.state["status"]["gates_off"] = gates_off
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.gate-off", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
|
||||
cooling.gate_off(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
# -- cases ------------------------------------------------------------------
|
||||
def test_trip_then_off_then_restored_passes(self):
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
|
||||
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
|
||||
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
||||
posts = self.settings_posts()
|
||||
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
|
||||
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
|
||||
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||
|
||||
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
||||
self.trips = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("did not trip", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
# The restore cycles a run session so the engine re-reads the
|
||||
# restored values; the bench is not left holding on the test's.
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 2)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 2)
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_every_session_waits_for_the_previous_one_to_end(self):
|
||||
"""Each M8 must find the engine out of phase run, or the engine
|
||||
never re-reads: the bench failure behind this case sent M9 and
|
||||
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
|
||||
the session end."""
|
||||
seen = []
|
||||
inner = self._engine
|
||||
|
||||
def engine(line):
|
||||
if line == "M8":
|
||||
seen.append(self.fc.state["cool"]["phase"])
|
||||
inner(line)
|
||||
self.grbl.on_command = engine
|
||||
self.run_test()
|
||||
self.assertEqual(seen, ["idle", "idle", "idle"])
|
||||
self.assertEqual(self.sessions, 3)
|
||||
|
||||
def test_an_engine_that_hides_the_off_gate_fails(self):
|
||||
self.report_off = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("gates_off", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
|
||||
def test_a_missing_run_start_log_line_fails(self):
|
||||
self.log_line = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("run-start log line", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
|
||||
def test_an_earlier_runs_line_is_not_this_runs(self):
|
||||
# The off gate's line from a run before this one is in the log, and
|
||||
# the engine writes none this time: the line judged is one written
|
||||
# after the test's own mark, so the old one does not pass for it.
|
||||
self.emit("Aug 21 11:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
||||
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
||||
self.log_line = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("run-start log line", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
|
||||
def test_a_missing_run_end_temperature_line_fails(self):
|
||||
self.temps_line = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("board-temperature line", str(cm.exception))
|
||||
|
||||
def test_a_custom_ceiling_is_restored_verbatim(self):
|
||||
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||
self._describe()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
|
||||
|
||||
|
||||
class CriticalTierTests(EngineLog, unittest.TestCase):
|
||||
"""cooling.critical-tier against a scripted engine: two tiers on the
|
||||
coolant, the fail tier winning over the pause tier in a run session
|
||||
and ending with it, the critical line off at its top, and the settings
|
||||
cross-check refusing a critical line at or below the ceiling."""
|
||||
|
||||
UP = 22.3
|
||||
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
|
||||
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
|
||||
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
|
||||
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.log_setup()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 3
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
|
||||
gates_off=[])
|
||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||
"gates_off": []}
|
||||
for k in self.ROWS:
|
||||
self.fc.state["settings"].setdefault(k, "")
|
||||
self.fc.state["logs_tail"] = {"text": ""}
|
||||
self.faults = True # the engine has the critical tier
|
||||
self.ends_with_session = True
|
||||
self.cross_checks = True # the settings API refuses crit <= max
|
||||
self._describe()
|
||||
self.grbl.on_command = self._engine
|
||||
self.fc.on_post = self._on_post
|
||||
|
||||
def tearDown(self):
|
||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
self.log_teardown()
|
||||
|
||||
def setting(self, key):
|
||||
v = self.fc.state["settings"].get(key) or ""
|
||||
return float(v) if v else self.ROWS[key][1]
|
||||
|
||||
def _describe(self):
|
||||
gates = {}
|
||||
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
|
||||
v = self.setting(k)
|
||||
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
|
||||
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
|
||||
"value": v, "state": state}
|
||||
self.fc.state["settings"]["gates"] = gates
|
||||
|
||||
def _on_post(self, path, form):
|
||||
if path != "/settings":
|
||||
return None
|
||||
if self.cross_checks:
|
||||
merged = dict(self.fc.state["settings"])
|
||||
merged.update(form)
|
||||
tmax = float(merged.get("cool_temp_max") or 33.0)
|
||||
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
|
||||
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
|
||||
if not ceiling_off and tcrit <= tmax:
|
||||
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
|
||||
self.fc.state["settings"].update(form)
|
||||
self._describe()
|
||||
return (200, self.fc.state["settings"])
|
||||
|
||||
def _verdict_idle(self):
|
||||
"""Outside a run only the ceiling's pause tier stands."""
|
||||
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
|
||||
|
||||
def _engine(self, line):
|
||||
self._describe()
|
||||
cool = self.fc.state["cool"]
|
||||
if line == "M9":
|
||||
def end():
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "idle"
|
||||
if self.ends_with_session or cool["verdict"] != "CRITICAL":
|
||||
v = self._verdict_idle()
|
||||
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
cool["phase"] = "run"
|
||||
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
|
||||
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
|
||||
off = ["coolant_critical"] if crit_off else []
|
||||
if crit_off:
|
||||
self.emit(
|
||||
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
|
||||
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
|
||||
if self.faults and not crit_off and self.UP >= tcrit:
|
||||
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
|
||||
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
|
||||
"no resume this job" % (self.UP, tcrit), gates_off=off)
|
||||
elif self.UP > tmax:
|
||||
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
|
||||
reason="coolant over the ceiling", gates_off=off)
|
||||
else:
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
|
||||
self.fc.state["status"]["gates_off"] = off
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.critical-tier", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
|
||||
cooling.critical_tier(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
# -- cases ------------------------------------------------------------------
|
||||
def test_fault_then_off_then_restored_passes(self):
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["cross_check"]["status"], 400)
|
||||
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
|
||||
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
|
||||
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
|
||||
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
||||
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
|
||||
posts = self.settings_posts()
|
||||
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
|
||||
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
|
||||
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
|
||||
"cool_temp_critical_c": "7.0"})
|
||||
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||
|
||||
def test_an_engine_without_the_tier_fails_and_restores(self):
|
||||
self.faults = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("did not fault", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1],
|
||||
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_a_fault_that_outlives_its_session_fails(self):
|
||||
self.ends_with_session = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("after the faulted session", str(cm.exception))
|
||||
|
||||
def test_a_missing_cross_check_fails_before_any_session(self):
|
||||
self.cross_checks = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("was accepted", str(cm.exception))
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
||||
|
||||
|
||||
class FanGateTests(unittest.TestCase):
|
||||
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
|
||||
floors at every M8 (the engine reloads at run start), holds every fan
|
||||
in grace for the configured seconds, then judges the bench readings
|
||||
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
|
||||
against the floors: a floor a reading cannot meet trips AIRFLOW three
|
||||
ticks after the grace, a floor of zero reads off."""
|
||||
|
||||
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
|
||||
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
|
||||
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
||||
"intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
||||
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
|
||||
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
|
||||
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
|
||||
"air_assist": "air_assist", "purge": "purge"}
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 4
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
cooling.FAN_TRIP_WAIT_S = 4
|
||||
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
|
||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||
"gates_off": [], "fan_gates": {}}
|
||||
for key, _d, _h in self.FLOORS.values():
|
||||
self.fc.state["settings"].setdefault(key, "")
|
||||
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
|
||||
self.trips = True # the engine trips an unmeetable floor
|
||||
self.reports_off = True # the engine reports a zero floor as off
|
||||
self.grace_scale = 0.1 # seconds of fake grace per configured second
|
||||
self._describe()
|
||||
self.grbl.on_command = self._engine
|
||||
self.fc.on_post = self._on_post
|
||||
|
||||
def tearDown(self):
|
||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
|
||||
def setting(self, key, default):
|
||||
v = self.fc.state["settings"].get(key) or ""
|
||||
return float(v) if v else default
|
||||
|
||||
def _describe(self):
|
||||
gates = {}
|
||||
for fan, (key, default, hi) in self.FLOORS.items():
|
||||
v = self.setting(key, default)
|
||||
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
|
||||
"band": [default * 0.7, default * 1.4], "off": "low", "value": v,
|
||||
"state": "off" if v <= 0 else "ok"}
|
||||
g = self.setting("cool_fan_grace_s", 15.0)
|
||||
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
|
||||
"off": "none", "value": g, "state": "ok"}
|
||||
self.fc.state["settings"]["gates"] = gates
|
||||
|
||||
def _on_post(self, path, form):
|
||||
if path != "/settings":
|
||||
return None
|
||||
self.fc.state["settings"].update(form)
|
||||
self._describe()
|
||||
return (200, self.fc.state["settings"])
|
||||
|
||||
def _engine(self, line):
|
||||
self._describe()
|
||||
cool = self.fc.state["cool"]
|
||||
if line == "M9":
|
||||
def end():
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "idle"
|
||||
# the fan fault is the session's: it ends with it
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
||||
for g in cool.get("fan_gates", {}).values():
|
||||
g["state"] = "idle"
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
cool["phase"] = "run"
|
||||
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
|
||||
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
|
||||
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
||||
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
|
||||
self.fc.state["status"]["gates_off"] = off
|
||||
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
|
||||
|
||||
def judge():
|
||||
time.sleep(grace)
|
||||
if cool["phase"] != "run":
|
||||
return
|
||||
for f in floors:
|
||||
if floors[f] > 0:
|
||||
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
||||
time.sleep(0.3)
|
||||
if cool["phase"] != "run" or not self.trips:
|
||||
return
|
||||
for f in floors:
|
||||
if 0 < floors[f] > self.READINGS[f]:
|
||||
cool["fan_gates"][f]["state"] = "TRIPPED"
|
||||
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
|
||||
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
|
||||
% (f, self.READINGS[f], floors[f]))
|
||||
break
|
||||
threading.Thread(target=judge, daemon=True).start()
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.fan-gate-trips", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
|
||||
cooling.fan_gate_trips(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
def test_trip_purge_off_and_restore_pass(self):
|
||||
run = self.run_test()
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
|
||||
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
|
||||
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
|
||||
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
|
||||
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
||||
posts = self.settings_posts()
|
||||
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
|
||||
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
||||
"cool_fan_grace_s": ""})
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 4)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 4)
|
||||
|
||||
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
||||
self.trips = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("did not trip", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
||||
"cool_fan_grace_s": ""})
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_an_engine_that_hides_an_off_floor_fails(self):
|
||||
self.reports_off = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("lacks exhaust", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||
from forgetest.suite import cooling
|
||||
|
||||
|
||||
class FlowVerdictLogTests(unittest.TestCase):
|
||||
@@ -817,5 +59,6 @@ class FlowVerdictLogTests(unittest.TestCase):
|
||||
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||
|
||||
def test_a_missing_log_reads_as_nothing(self):
|
||||
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -7,19 +7,16 @@
|
||||
|
||||
The fixture is a box on the network that opens the lid loop, pulls the
|
||||
interlock loop and presses the button on request. What has to hold: the
|
||||
tool finds it by name without a resolver on the image, speaks its API
|
||||
under the key, falls back to the operator when it cannot act, moves an
|
||||
operator test it can perform alone into the unattended queue (never a
|
||||
live one, never one that needs a person for anything else), refuses a
|
||||
prompt during such a run rather than hanging on it, releases what the
|
||||
box still holds after every run, and presses the arm button only where
|
||||
the bench opted in.
|
||||
tool speaks its API under the key, falls back to the operator when it
|
||||
cannot act, moves an operator test it can perform alone into the
|
||||
unattended queue (never a live one, never one that needs a person for
|
||||
anything else), refuses a prompt during such a run rather than hanging on
|
||||
it, releases what the box still holds after every run, and presses the
|
||||
arm button only where the bench opted in.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -30,7 +27,7 @@ import helpers
|
||||
from forgetest import fixture as fx
|
||||
from forgetest import runner as runner_mod
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Failed, Run, Runner
|
||||
from forgetest.runner import Context, Run, Runner
|
||||
|
||||
KEY = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
@@ -113,38 +110,6 @@ class FakeFixture:
|
||||
self.httpd.server_close()
|
||||
|
||||
|
||||
class MdnsTests(unittest.TestCase):
|
||||
def test_query_packet(self):
|
||||
q = fx.mdns_query("forgefixture.local")
|
||||
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
|
||||
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
|
||||
|
||||
def _response(self, name, ip, extra_name=None):
|
||||
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
|
||||
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
|
||||
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
|
||||
if extra_name:
|
||||
# a second answer naming the first through a compression pointer
|
||||
# to offset 12 (the first name), plus its own first label
|
||||
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
|
||||
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
|
||||
return hdr + rr
|
||||
|
||||
def test_answers_are_the_named_a_records(self):
|
||||
data = self._response("forgefixture.local", "192.0.2.50")
|
||||
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
|
||||
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
|
||||
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
|
||||
# a query (QR clear) is never an answer
|
||||
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
|
||||
# compression pointers are followed; the other record is not ours
|
||||
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
|
||||
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
|
||||
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
|
||||
# garbage does not raise
|
||||
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||
@@ -231,37 +196,6 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("button", "press")
|
||||
self.assertIn("jumper", str(cm.exception))
|
||||
|
||||
def test_two_presses_are_spaced_so_the_controller_sees_the_release(self):
|
||||
f = self.client()
|
||||
f.status()
|
||||
t0 = time.time()
|
||||
f.act("button", "press")
|
||||
f.act("button", "press")
|
||||
took = time.time() - t0
|
||||
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
|
||||
self.assertEqual(len(posts), 2) # no 409 round trip was needed
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
# the second waited for the first pulse (200 ms as reported) and the gap
|
||||
self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05)
|
||||
|
||||
def test_a_pulse_in_progress_is_waited_out_then_retried(self):
|
||||
f = self.client()
|
||||
f.status()
|
||||
self.dev.state["button"] = "pressed" # a press this client did not time
|
||||
threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start()
|
||||
f.act("button", "press")
|
||||
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
|
||||
self.assertEqual(len(posts), 2) # the 409, then the press
|
||||
self.assertEqual(self.dev.presses, 1)
|
||||
|
||||
def test_a_pulse_that_never_ends_is_the_fixtures_error(self):
|
||||
f = self.client()
|
||||
f.status()
|
||||
self.dev.state["button"] = "pressed"
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
f.act("button", "press")
|
||||
self.assertIn("in progress", str(cm.exception))
|
||||
|
||||
def test_a_wrong_key_is_refused(self):
|
||||
f = self.client(key="wrong")
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
@@ -279,10 +213,9 @@ class ClientTests(unittest.TestCase):
|
||||
self.assertTrue(f.covers("lid"))
|
||||
self.assertFalse(f.covers("interlock"))
|
||||
|
||||
def test_probe_without_a_config_is_none_and_a_silent_box_is_logged(self):
|
||||
def test_probe_without_a_config_or_with_a_silent_box_is_none(self):
|
||||
lines = []
|
||||
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
|
||||
self.assertEqual(lines, [])
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||
try:
|
||||
p = os.path.join(tmp, "fixture.json")
|
||||
@@ -291,7 +224,6 @@ class ClientTests(unittest.TestCase):
|
||||
# port 1 on localhost: nothing listens there
|
||||
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
||||
self.assertIsNone(f2)
|
||||
self.assertTrue(lines and "running without it" in lines[-1], lines)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
@@ -430,39 +362,6 @@ class RoutingTests(unittest.TestCase):
|
||||
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
||||
self.assertEqual(summary["hostname"], "forgefixture")
|
||||
|
||||
def test_without_the_jumper_the_button_tests_stay_attended(self):
|
||||
self.stub.button_enabled = False
|
||||
av, _ = self.selection()
|
||||
self.assertIn("r.btn", av["attended"])
|
||||
self.assertIn("r.lid", av["unattended"])
|
||||
|
||||
def test_no_fixture_is_the_old_routing(self):
|
||||
fx.probe = lambda log, path=None, resolver=None: None
|
||||
self.runner._fixture_probed = 0.0
|
||||
av, summary = self.selection()
|
||||
self.assertEqual(av["unattended"], ["r.auto"])
|
||||
self.assertIsNone(summary)
|
||||
|
||||
def test_a_queue_started_during_a_probe_waits_for_the_fixture(self):
|
||||
# The daemon just came up: nothing probed yet, a page poll starts
|
||||
# the first probe (slow: an mDNS answer), and the queue start
|
||||
# lands while it is in flight. The start must see the fixture.
|
||||
stub = self.stub
|
||||
|
||||
def slow_probe(log, path=None, resolver=None):
|
||||
time.sleep(0.5)
|
||||
return stub
|
||||
fx.probe = slow_probe
|
||||
self.runner._fixture_probed = 0.0
|
||||
self.runner.fixture = None
|
||||
poll = threading.Thread(target=self.runner.probe_fixture)
|
||||
poll.start()
|
||||
time.sleep(0.1)
|
||||
order = self.runner.batch_selection("unattended")
|
||||
poll.join()
|
||||
self.assertIn("r.lid", order)
|
||||
self.assertIn("r.btn", order)
|
||||
|
||||
def run_queue(self, group, ack_live=False):
|
||||
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
||||
self.assertTrue(ok, msg)
|
||||
@@ -495,20 +394,6 @@ class RoutingTests(unittest.TestCase):
|
||||
asks = self.last_result("r.asks")
|
||||
self.assertIn("declare the step in hands=", asks["message"])
|
||||
|
||||
def test_a_failing_box_hands_the_action_to_the_operator(self):
|
||||
self.stub.fail = True
|
||||
run = Run("test", "r.lid", "r.lid")
|
||||
ctx = Context(run, self.runner, self.reg["r.lid"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
seen = []
|
||||
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
|
||||
run.set_notice = lambda text: seen.append(text)
|
||||
ctx.act("lid", "open")
|
||||
rec = run.evidence["actions"][0]
|
||||
self.assertEqual(rec["by"], "operator")
|
||||
self.assertIn("the box is off", rec["fixture_error"])
|
||||
self.assertTrue(seen and "lid" in seen[0].lower())
|
||||
|
||||
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
|
||||
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
|
||||
# hand that is not there; it ends at once, as the harness's error
|
||||
@@ -563,36 +448,6 @@ class RoutingTests(unittest.TestCase):
|
||||
self.assertEqual(rec[0]["by"], "operator")
|
||||
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
|
||||
|
||||
def test_presence_is_proved_once_per_test_not_once_per_gate(self):
|
||||
"""A test with two armed halves reaches the ready gate twice. The
|
||||
second one must not ask for another press: the operator proved
|
||||
presence a moment earlier and the actuator has the presses. The
|
||||
setup line still goes up, because the second half may want the
|
||||
scrap moved."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
asked = []
|
||||
run.ask = lambda q, o: asked.append(q)
|
||||
self.press_button(False)
|
||||
threading.Timer(0.3, self.press_button, args=(True,)).start()
|
||||
threading.Timer(0.8, self.press_button, args=(False,)).start()
|
||||
ctx.ready("First half.")
|
||||
self.assertTrue(run.fixture_takeover)
|
||||
presses = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
||||
self.assertEqual(len(presses), 1)
|
||||
|
||||
# the second gate: returns at once, no new press, notice still shown
|
||||
seen = []
|
||||
run.set_notice = lambda text: seen.append(text)
|
||||
t0 = time.time()
|
||||
ctx.ready("Second half. Move the scrap.")
|
||||
self.assertLess(time.time() - t0, 1.0)
|
||||
self.assertEqual(asked, [])
|
||||
self.assertEqual(len([r for r in run.evidence["actions"] if r["state"] == "presence"]), 1)
|
||||
self.assertIn("Second half. Move the scrap.", seen)
|
||||
self.assertTrue(any("presence already proved" in ln for ln in run.lines))
|
||||
|
||||
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
|
||||
"""The bench's standing opt-in is not needed once the operator has
|
||||
proved presence: that press is what the opt-in existed to
|
||||
@@ -613,68 +468,6 @@ class RoutingTests(unittest.TestCase):
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
|
||||
"""The machine's own press prompt, not a button LED read from
|
||||
here: the button is lit through parts of a live check that are
|
||||
not the arm, and a press then lands before the job waits for it
|
||||
and is lost. press_now presses at the moment the check asks, with
|
||||
no LED reading and no waiting thread."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
self.stub.arm_press = False
|
||||
run.fixture_takeover = True
|
||||
saved = runner_mod.hw.button_lit
|
||||
runner_mod.hw.button_lit = lambda: False # never consulted
|
||||
try:
|
||||
self.assertTrue(ctx.press_now())
|
||||
self.assertIn(("button", "press"), self.stub.acts)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
|
||||
self.assertEqual(rec["by"], "fixture")
|
||||
|
||||
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
|
||||
"""No actuator, or one that never took this test's presses: the
|
||||
notice goes up for a person, who is looking at the same prompt."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
self.stub.arm_press = False
|
||||
run.fixture_takeover = False
|
||||
seen = []
|
||||
run.set_notice = lambda text: seen.append(text)
|
||||
self.assertFalse(ctx.press_now("Press it now."))
|
||||
self.assertNotIn(("button", "press"), self.stub.acts)
|
||||
self.assertIn("Press it now.", seen)
|
||||
|
||||
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
|
||||
"""A card that settles the coolant before it lights the button
|
||||
takes minutes; the caller names the wait, and the actuator presses
|
||||
when the light comes instead of handing the press to a person at
|
||||
the default 60 s (found on the bench by the flow-load card)."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
run.fixture_takeover = True
|
||||
lit_at = time.time() + 0.6
|
||||
saved = runner_mod.hw.button_lit
|
||||
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
|
||||
seen = []
|
||||
run.set_notice = lambda text: seen.append(text)
|
||||
try:
|
||||
self.assertTrue(ctx.arm_press(lit_timeout=5))
|
||||
deadline = time.time() + 5
|
||||
while ("button", "press") not in self.stub.acts and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertIn(("button", "press"), self.stub.acts)
|
||||
self.assertEqual([n for n in seen if n], []) # nobody was asked
|
||||
rec = run.evidence["actions"][-1]
|
||||
self.assertEqual(rec["by"], "fixture")
|
||||
self.assertGreaterEqual(rec["took_s"], 0.5)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
|
||||
"""Falling back to the operator without a word is how one dropped
|
||||
actuator becomes a press nobody can account for afterwards."""
|
||||
@@ -689,21 +482,6 @@ class RoutingTests(unittest.TestCase):
|
||||
rec = run.evidence["actions"][-1]
|
||||
self.assertTrue(rec.get("fixture_lost"))
|
||||
self.assertEqual(rec["by"], "operator")
|
||||
self.assertTrue(any("WARNING" in ln and "now gone" in ln for ln in run.lines))
|
||||
|
||||
def test_ready_still_asks_the_page_with_no_actuator(self):
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.fixture = None
|
||||
asked = []
|
||||
|
||||
def ask(q, o):
|
||||
asked.append((q, list(o)))
|
||||
return "Ready"
|
||||
run.ask = ask
|
||||
ctx.ready("LIVE FIRE. Scrap under the head.")
|
||||
self.assertEqual(asked, [("LIVE FIRE. Scrap under the head.", ["Ready", "Cannot"])])
|
||||
self.assertFalse(run.fixture_takeover)
|
||||
|
||||
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
|
||||
run = Run("test", "r.live", "r.live")
|
||||
|
||||
@@ -5,38 +5,15 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from forgetest import catalog
|
||||
from forgetest.suite import image
|
||||
|
||||
|
||||
class KernelIdentTests(unittest.TestCase):
|
||||
def test_localversion_hash_is_stripped(self):
|
||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
||||
|
||||
def test_plain_name_is_unchanged(self):
|
||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc"), "6.12.20-fslc-fslc")
|
||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-g12"), "6.12.20-fslc-g12")
|
||||
|
||||
def test_release_matches_manifest_with_or_without_hash(self):
|
||||
rel = "6.12.20-fslc-fslc-g72a0b1431a9d"
|
||||
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"]))
|
||||
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"]))
|
||||
self.assertTrue(image.kernel_matches(rel, []))
|
||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||
|
||||
|
||||
class NetworkBootTests(unittest.TestCase):
|
||||
STANZA = ("auto wlan0\n"
|
||||
"iface wlan0 inet dhcp\n"
|
||||
"\twpa-driver nl80211\n"
|
||||
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||
|
||||
def test_the_test_is_registered_and_covers_nothing_by_design(self):
|
||||
t = catalog.load_suite()["image.network-boot"]
|
||||
self.assertEqual(t.kind, "auto")
|
||||
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
||||
|
||||
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||
|
||||
@@ -9,10 +9,9 @@ The installer runs once per machine, on factory firmware, over whatever
|
||||
network the owner has. These tests run its own functions under sh with a
|
||||
scripted curl in front of them: a transfer that fails resumes and is
|
||||
tried again, the failures that waiting cannot fix end the tries at once,
|
||||
and every step leaves a line in the install log in the log tree's own
|
||||
format."""
|
||||
the reason an install stopped is in the install log, and a log that cannot
|
||||
be written never fails the install."""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -25,10 +24,6 @@ REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
|
||||
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
||||
MARK = "# The install log, before anything can fail."
|
||||
|
||||
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
||||
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
||||
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
||||
|
||||
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||
@@ -149,24 +144,11 @@ class InstallerTests(unittest.TestCase):
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||
|
||||
def test_a_tls_failure_names_the_clock(self):
|
||||
out, _, _ = self.download(["60 000 0"] * 5)
|
||||
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
||||
|
||||
def test_every_log_line_is_in_the_tree_format(self):
|
||||
self.download(["7 000 0", "0 200 10"])
|
||||
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
||||
lines = self.log_lines()
|
||||
self.assertGreater(len(lines), 5)
|
||||
for line in lines:
|
||||
self.assertRegex(line, LINE_RE)
|
||||
|
||||
def test_die_leaves_the_reason_in_the_log(self):
|
||||
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertNotIn("not-reached", r.stdout)
|
||||
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
||||
for line in self.log_lines()))
|
||||
self.assertTrue(any("archiving slot 1 failed" in line for line in self.log_lines()))
|
||||
|
||||
def test_no_writable_log_never_fails_the_install(self):
|
||||
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||
@@ -174,21 +156,5 @@ class InstallerTests(unittest.TestCase):
|
||||
self.assertIn("still-running rc=0", r.stdout)
|
||||
|
||||
|
||||
class InstallerShapeTests(unittest.TestCase):
|
||||
"""What holds on any host, with no shell to run."""
|
||||
|
||||
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
||||
head = functions()
|
||||
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
||||
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
||||
|
||||
def test_the_download_is_the_retrying_one(self):
|
||||
with open(SCRIPT, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
body = text.partition(MARK)[2]
|
||||
self.assertIn("download_fw", body)
|
||||
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -83,27 +83,6 @@ class SwitchModeTests(unittest.TestCase):
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
|
||||
def test_the_supervisors_levers_get_the_slow_timeout(self):
|
||||
from forgetest import hw
|
||||
fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0)
|
||||
self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0)
|
||||
self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0)
|
||||
self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0)
|
||||
self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0)
|
||||
self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0)
|
||||
self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait
|
||||
# a switch that answers after the ordinary timeout still lands
|
||||
import time
|
||||
|
||||
def on_post(path, form):
|
||||
time.sleep(1.5)
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud")
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||
self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||
|
||||
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
|
||||
cloud(self.fc)
|
||||
b = self.bl()
|
||||
|
||||
@@ -10,10 +10,8 @@ it is needed and a bench actuator can later perform: `act` names a
|
||||
machine action and watches the machine for it (a standing notice for
|
||||
the human, no button to race), `ready` pre-announces a timed step, and
|
||||
a `precheck` refuses a start the machine cannot honor instead of
|
||||
recording a FAIL. Runner-level events go to the journal, never to the
|
||||
page state.
|
||||
recording a FAIL.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -24,16 +22,7 @@ import unittest
|
||||
import helpers
|
||||
from forgetest import catalog
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Failed, Run, Runner, journal
|
||||
|
||||
|
||||
class Catch(logging.Handler):
|
||||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
self.lines = []
|
||||
|
||||
def emit(self, rec):
|
||||
self.lines.append(rec.getMessage())
|
||||
from forgetest.runner import Context, Failed, Run, Runner
|
||||
|
||||
|
||||
class Answerer:
|
||||
@@ -95,11 +84,6 @@ class ActTests(unittest.TestCase):
|
||||
self.assertIsNotNone(rec["took_s"])
|
||||
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
|
||||
|
||||
def test_the_tests_context_follows_the_wording(self):
|
||||
self.lid(False)
|
||||
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
|
||||
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." in ln for ln in self.run.lines))
|
||||
|
||||
def test_already_done_returns_at_once(self):
|
||||
self.lid(False)
|
||||
dt = self.ctx.act("lid", "open", timeout=2)
|
||||
@@ -134,31 +118,6 @@ class ActTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.ctx.act("lid", "press")
|
||||
|
||||
def test_a_fixture_covering_the_channel_performs_it(self):
|
||||
class Fixture:
|
||||
done = []
|
||||
|
||||
def covers(self, channel):
|
||||
return channel == "lid"
|
||||
|
||||
def act(self, channel, state, ms=None):
|
||||
self.done.append((channel, state) if ms is None else (channel, state, ms))
|
||||
self_outer.lid(state != "open")
|
||||
self_outer = self
|
||||
|
||||
class R:
|
||||
fixture = Fixture()
|
||||
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
|
||||
ctx.act("lid", "open", timeout=3)
|
||||
self.assertEqual(Fixture.done, [("lid", "open")])
|
||||
self.assertEqual(self.ans.notices, [])
|
||||
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
|
||||
# a channel the fixture does not cover falls back to the operator
|
||||
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||
ctx.act("interlock", "open", timeout=3)
|
||||
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
|
||||
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
|
||||
|
||||
def test_ready_is_a_single_button_prompt(self):
|
||||
self.ctx.ready("On Ready the head moves.")
|
||||
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
|
||||
@@ -177,13 +136,6 @@ class ActTests(unittest.TestCase):
|
||||
with self.assertRaises(Failed):
|
||||
self.ctx.ready("x")
|
||||
|
||||
def test_notice_is_logged_and_in_the_snapshot(self):
|
||||
self.ctx.notice("Hold still.")
|
||||
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
|
||||
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
|
||||
self.ctx.clear_notice()
|
||||
self.assertIsNone(self.run.snapshot()["notice"])
|
||||
|
||||
|
||||
class PrecheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -262,48 +214,6 @@ class PrecheckTests(unittest.TestCase):
|
||||
catalog.REGISTRY.update(saved)
|
||||
|
||||
|
||||
class JournalTests(unittest.TestCase):
|
||||
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
|
||||
os.environ["FORGETEST_DATA"] = tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
|
||||
h = Catch()
|
||||
journal.addHandler(h)
|
||||
try:
|
||||
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
|
||||
helpers.registry(helpers.make_test("j.a", [])))
|
||||
r._note("queue unattended: 1 test(s) to run")
|
||||
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
|
||||
self.assertNotIn("messages", r.state()[0])
|
||||
run = Run("test", "j.a", "j.a")
|
||||
r.current = run
|
||||
r._note("queue unattended: stop requested")
|
||||
self.assertTrue(any("stop requested" in ln for ln in run.lines))
|
||||
finally:
|
||||
journal.removeHandler(h)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def test_journal_tail_serves_the_daemon_log(self):
|
||||
from forgetest.server import journal_tail
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
|
||||
try:
|
||||
p = os.path.join(tmp, "daemon.log")
|
||||
self.assertIn(b"no journal yet", journal_tail(p))
|
||||
with open(p, "wb") as f:
|
||||
for i in range(2000):
|
||||
f.write(b"line %d\n" % i)
|
||||
tail = journal_tail(p, max_bytes=200)
|
||||
self.assertTrue(tail.endswith(b"line 1999\n"))
|
||||
self.assertLess(len(tail), 200)
|
||||
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class AccelSamplerTests(unittest.TestCase):
|
||||
"""The head accelerometer sampler over a fake iio tree."""
|
||||
|
||||
@@ -353,3 +263,7 @@ class AccelSamplerTests(unittest.TestCase):
|
||||
with s:
|
||||
pass
|
||||
self.assertEqual(s.p2p(0), (0, 0, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -28,7 +28,7 @@ import urllib.request
|
||||
import helpers
|
||||
from forgetest import catalog, server
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import BATCH_GROUPS, Failed, Runner
|
||||
from forgetest.runner import BATCH_GROUPS, Runner
|
||||
|
||||
|
||||
def t_pass(ctx):
|
||||
@@ -53,33 +53,17 @@ class OrderTests(unittest.TestCase):
|
||||
def order(self, tests, selected):
|
||||
return catalog.order_by_requires(tests, selected)
|
||||
|
||||
def test_prerequisite_first(self):
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||
# selection order must not matter; registration order breaks ties
|
||||
self.assertEqual(self.order([b, a], ["s.b", "s.a"]), ["s.a", "s.b"])
|
||||
self.assertEqual(self.order([a, b], ["s.b", "s.a"]), ["s.a", "s.b"])
|
||||
|
||||
def test_chain(self):
|
||||
c = helpers.make_test("s.c", [], requires=("s.b",), fn=t_pass)
|
||||
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
self.assertEqual(self.order([c, b, a], ["s.c", "s.b", "s.a"]), ["s.a", "s.b", "s.c"])
|
||||
|
||||
def test_registration_order_otherwise(self):
|
||||
ts = [helpers.make_test("s.%d" % i, [], fn=t_pass) for i in range(4)]
|
||||
self.assertEqual(self.order(ts, [t.id for t in ts]), ["s.0", "s.1", "s.2", "s.3"])
|
||||
|
||||
def test_prerequisite_outside_the_selection_places_nothing(self):
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||
self.assertEqual(self.order([a, b], ["s.b"]), ["s.b"])
|
||||
|
||||
def test_unknown_and_empty(self):
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
self.assertEqual(self.order([a], []), [])
|
||||
self.assertEqual(self.order([a], ["s.nope"]), [])
|
||||
|
||||
def test_a_cycle_does_not_recurse_forever(self):
|
||||
"""validate() rejects cycles; a malformed registry must still not
|
||||
hang the runner."""
|
||||
@@ -89,32 +73,6 @@ class OrderTests(unittest.TestCase):
|
||||
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
||||
|
||||
|
||||
class RunClockTests(unittest.TestCase):
|
||||
"""The page shows the last run until the next one starts, so a
|
||||
finished run's elapsed figure must be the time it took, not the time
|
||||
since. Found on the bench reference, where a failed test went on
|
||||
counting past 5000 s and read as still running."""
|
||||
|
||||
def test_a_finished_runs_clock_stops(self):
|
||||
from forgetest.runner import Run
|
||||
run = Run("test", "t.x", "t.x")
|
||||
run.started = time.time() - 30
|
||||
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
|
||||
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
|
||||
first = run.snapshot()["elapsed_s"]
|
||||
time.sleep(1.1)
|
||||
self.assertEqual(first, 31)
|
||||
self.assertEqual(run.snapshot()["elapsed_s"], 31)
|
||||
|
||||
def test_a_running_runs_clock_ticks(self):
|
||||
from forgetest.runner import Run
|
||||
run = Run("test", "t.x", "t.x")
|
||||
run.started = time.time() - 5
|
||||
first = run.snapshot()["elapsed_s"]
|
||||
time.sleep(1.1)
|
||||
self.assertGreater(run.snapshot()["elapsed_s"], first)
|
||||
|
||||
|
||||
class QueueTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -322,13 +280,13 @@ class QueueTests(unittest.TestCase):
|
||||
time.sleep(0.02)
|
||||
ok, msg = r.start_test("s.other")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(msg, "a queue is running")
|
||||
self.assertTrue(msg)
|
||||
ok, msg = r.start_bench("anything")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(msg, "a queue is running")
|
||||
self.assertTrue(msg)
|
||||
ok, msg, _ = r.start_batch("unattended")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("already running", msg)
|
||||
self.assertTrue(msg)
|
||||
r.stop_batch()
|
||||
r.abort()
|
||||
deadline = time.time() + 20
|
||||
@@ -366,7 +324,7 @@ class QueueTests(unittest.TestCase):
|
||||
def test_unknown_group(self):
|
||||
st, d = self.call("POST", "/batch", {"group": "everything"})
|
||||
self.assertEqual(st, 409)
|
||||
self.assertEqual(d["message"], "unknown queue")
|
||||
self.assertTrue(d["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,37 +3,22 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""What keeps the page answering the operator instead of the timer.
|
||||
"""The caches behind the page's state.
|
||||
|
||||
Three things went wrong on the bench and are pinned here:
|
||||
|
||||
- the state cost. Every poll re-read and re-parsed the whole result log,
|
||||
and recomputed every test's domain fingerprint. A result record carries
|
||||
its run log, so the file reaches megabytes over a campaign and the poll
|
||||
grew with it. Both are now parsed and computed once.
|
||||
- the first state's cost. Hashing the implementations parsed a test's
|
||||
module and every module it imports once per test, and each poll the
|
||||
page timed out on started the same work again beside the first. Each
|
||||
module is now parsed once, and a poll waits for the work in progress.
|
||||
- the wasted payload. An idle page polls an unchanged state; it now gets
|
||||
a 304 instead of the whole thing.
|
||||
|
||||
The click-swallowing defect these were found with lives in the page's
|
||||
JavaScript and is not reachable from here: rows, prompt buttons and tool
|
||||
entries are built once and afterwards only updated in place, because a
|
||||
poll that rebuilt them removed the button between the operator's mousedown
|
||||
and mouseup, and no click event was ever raised. `test_page_never_rebuilds`
|
||||
holds the shape of that rule.
|
||||
Every poll used to re-read and re-parse the whole result log and recompute
|
||||
every test's domain fingerprint; a result record carries its run log, so
|
||||
the file reaches megabytes over a campaign. Both are now kept between
|
||||
polls, and a stale cache would misjudge the gate: the log cache picks up
|
||||
what is appended or replaced and never counts a partial line, and the
|
||||
fingerprint cache never serves a changed manifest or one it cannot key.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import catalog, manifest as manifest_mod, page
|
||||
from forgetest import manifest as manifest_mod
|
||||
from forgetest.log import Log
|
||||
|
||||
|
||||
@@ -57,9 +42,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
for line in lines:
|
||||
f.write(line + "\n")
|
||||
|
||||
def test_missing_file_reads_empty(self):
|
||||
self.assertEqual(self.log.read(), [])
|
||||
|
||||
def test_appends_are_picked_up(self):
|
||||
self.log.append({"t": "campaign", "id": "c1"})
|
||||
self.assertEqual([r["id"] for r in self.log.read()], ["c1"])
|
||||
@@ -69,27 +51,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
self.assertEqual([r["t"] for r in recs], ["campaign", "result", "result"])
|
||||
self.assertEqual(recs[2]["test"], "c.d")
|
||||
|
||||
def test_only_new_bytes_are_parsed(self):
|
||||
for i in range(5):
|
||||
self.log.append({"t": "result", "test": "t%d" % i})
|
||||
self.log.read()
|
||||
# a second read must not touch the file at all
|
||||
real_open = open
|
||||
opened = []
|
||||
|
||||
def counting_open(*a, **kw):
|
||||
opened.append(a[0])
|
||||
return real_open(*a, **kw)
|
||||
|
||||
import builtins
|
||||
builtins.open = counting_open
|
||||
try:
|
||||
recs = self.log.read()
|
||||
finally:
|
||||
builtins.open = real_open
|
||||
self.assertEqual(opened, [], "an unchanged log was re-opened")
|
||||
self.assertEqual(len(recs), 5)
|
||||
|
||||
def test_read_returns_a_fresh_list(self):
|
||||
"""Callers filter and sort the result; the cache must not be theirs."""
|
||||
self.log.append({"t": "result", "test": "a.b"})
|
||||
@@ -110,16 +71,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
|
||||
self.assertEqual(self.log.corrupt, 0)
|
||||
|
||||
def test_corrupt_lines_counted_once_not_per_read(self):
|
||||
self.write('{"t":"result","test":"a.b"}', "not json at all", '["not","a","record"]',
|
||||
'{"t":"result","test":"c.d"}')
|
||||
recs = self.log.read()
|
||||
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
|
||||
self.assertEqual(self.log.corrupt, 2)
|
||||
self.log.read()
|
||||
self.log.read()
|
||||
self.assertEqual(self.log.corrupt, 2, "corrupt lines re-counted on every read")
|
||||
|
||||
def test_replaced_file_is_read_again(self):
|
||||
self.write('{"t":"result","test":"a.b"}', '{"t":"result","test":"c.d"}')
|
||||
self.assertEqual(len(self.log.read()), 2)
|
||||
@@ -128,11 +79,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
recs = self.log.read()
|
||||
self.assertEqual([r.get("id") for r in recs], ["c9"])
|
||||
|
||||
def test_blank_lines_are_not_corrupt(self):
|
||||
self.write('{"t":"result","test":"a.b"}', "", " ", '{"t":"result","test":"c.d"}')
|
||||
self.assertEqual(len(self.log.read()), 2)
|
||||
self.assertEqual(self.log.corrupt, 0)
|
||||
|
||||
|
||||
class FingerprintCacheTests(unittest.TestCase):
|
||||
"""Memoized per manifest: the page recomputes every test's fingerprint
|
||||
@@ -142,11 +88,6 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
self.man = helpers.make_manifest()
|
||||
self.t = helpers.make_test("fake.fp", [("forgectrl", "src/ui.c")], fn=t_noop)
|
||||
|
||||
def test_repeat_calls_agree(self):
|
||||
first = self.t.fingerprint(self.man)
|
||||
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||
|
||||
def test_a_changed_manifest_is_not_served_from_the_cache(self):
|
||||
first = self.t.fingerprint(self.man)
|
||||
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||
@@ -154,11 +95,6 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
# and back again: the cache must not have latched the new one either
|
||||
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||
|
||||
def test_a_file_outside_the_coverage_does_not_move_it(self):
|
||||
first = self.t.fingerprint(self.man)
|
||||
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||
self.assertEqual(self.t.fingerprint(man2), first)
|
||||
|
||||
def test_manifest_without_a_content_hash_is_not_cached(self):
|
||||
"""The cache is keyed by the manifest's content hash. A manifest
|
||||
that has none must still fingerprint correctly, not collide."""
|
||||
@@ -169,150 +105,6 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
self.assertIsNone(a.content_sha)
|
||||
self.assertNotEqual(self.t.fingerprint(a), self.t.fingerprint(b))
|
||||
|
||||
def test_component_file_list_is_stable_and_read_only(self):
|
||||
files = self.man.files("forgectrl")
|
||||
self.assertEqual(files, self.man.files("forgectrl"))
|
||||
self.assertIsNone(self.man.files("no-such-component"))
|
||||
|
||||
|
||||
class ImplementationCostTests(unittest.TestCase):
|
||||
"""The first state after a start hashes every test's implementation.
|
||||
It parses each suite module once, and a poll that arrives while it
|
||||
runs waits for it: on the board a parse per test took minutes, and
|
||||
the page's timed-out polls each started the work again beside it."""
|
||||
|
||||
def test_each_suite_module_is_parsed_once(self):
|
||||
import ast
|
||||
import inspect
|
||||
reg = catalog.load_suite()
|
||||
paths = sorted({inspect.getsourcefile(t.fn) for t in catalog.all_tests(reg)})
|
||||
for p in os.listdir(catalog.suite_dir()):
|
||||
catalog.forget(os.path.join(catalog.suite_dir(), p))
|
||||
for p in paths:
|
||||
catalog.forget(p)
|
||||
real_parse, parsed = ast.parse, []
|
||||
|
||||
def counting_parse(source, *a, **kw):
|
||||
parsed.append(source[:40])
|
||||
return real_parse(source, *a, **kw)
|
||||
|
||||
ast.parse = counting_parse
|
||||
try:
|
||||
for t in catalog.all_tests(reg):
|
||||
catalog.implementation_sha(inspect.getsourcefile(t.fn), t.id)
|
||||
finally:
|
||||
ast.parse = real_parse
|
||||
modules = [p for p in os.listdir(catalog.suite_dir()) if p.endswith(".py")]
|
||||
self.assertLessEqual(len(parsed), len(modules),
|
||||
"%d parses for %d suite modules" % (len(parsed), len(modules)))
|
||||
|
||||
def test_a_concurrent_fill_waits_instead_of_repeating(self):
|
||||
import threading
|
||||
import time
|
||||
t = helpers.make_test("fake.impl", [("forgectrl", "src/ui.c")], fn=t_noop)
|
||||
real, calls = catalog.implementation_sha, []
|
||||
|
||||
def slow(path, test_id):
|
||||
calls.append(test_id)
|
||||
time.sleep(0.3)
|
||||
return real(path, test_id)
|
||||
|
||||
catalog.implementation_sha = slow
|
||||
try:
|
||||
got = []
|
||||
threads = [threading.Thread(target=lambda: got.append(t.source_sha)) for _ in range(3)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join(10)
|
||||
finally:
|
||||
catalog.implementation_sha = real
|
||||
self.assertEqual(calls, ["fake.impl"], "the implementation hash was computed %d times" % len(calls))
|
||||
self.assertEqual(len(set(got)), 1)
|
||||
self.assertEqual(len(got), 3)
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
|
||||
"""A poll must update rows, prompt buttons and tool entries in
|
||||
place. Assigning innerHTML to their containers on every poll is
|
||||
what swallowed the clicks; only the one-time build may do it."""
|
||||
html = page.render("0" * 32)
|
||||
for container, builder in (("groups", "buildGroups"), ("tools", "buildBench")):
|
||||
self.assertIn("function %s(" % builder, html,
|
||||
"no one-time builder for #%s" % container)
|
||||
assigns = html.count("$('%s').innerHTML=" % container)
|
||||
self.assertEqual(assigns, 1,
|
||||
"#%s is assigned innerHTML %d times; it belongs to the "
|
||||
"builder alone" % (container, assigns))
|
||||
# the per-poll path writes through the guarded setters only
|
||||
self.assertIn("function setHtml(e,h){if(e&&e.__h!==h)", html)
|
||||
self.assertIn("function updateGroups()", html)
|
||||
self.assertIn("function updateBench()", html)
|
||||
# the prompt buttons are rebuilt only when the prompt changes
|
||||
self.assertIn("if(pk!==promptKey)", html)
|
||||
# the queue controls are static markup: a poll relabels them and
|
||||
# flips disabled, it never replaces the node
|
||||
for bid in ("q-unattended", "q-attended", "q-stop"):
|
||||
self.assertIn('id="%s"' % bid, html)
|
||||
self.assertNotIn("id='" + bid + "-", html)
|
||||
self.assertNotIn('id="' + bid + "-", html)
|
||||
self.assertIn("function renderQueue()", html)
|
||||
# the help popovers sit on static markup only: a rebuilt row or
|
||||
# tool entry would orphan one
|
||||
self.assertNotIn("data-help", html[html.index("function buildGroups("):])
|
||||
|
||||
def test_every_group_is_built_on_the_same_grid(self):
|
||||
"""The subsystems are separate tables. Left to size themselves
|
||||
from their own content no two line up, which is what made the
|
||||
page look busy, so they share one colgroup and a fixed layout."""
|
||||
html = page.render("0" * 32)
|
||||
self.assertRegex(html, r"#groups table\s*\{\s*table-layout:\s*fixed")
|
||||
# one colgroup definition, emitted into every group's table
|
||||
self.assertEqual(html.count('var COLS="<colgroup>'), 1)
|
||||
self.assertEqual(html.count('<table>"+COLS+"'), 1,
|
||||
"a group table built without the shared colgroup")
|
||||
colgroup = re.search(r'var COLS="(.*?)";', html).group(1)
|
||||
heads = ["Test", "Kind", "Status", "Last result"]
|
||||
for h in heads:
|
||||
self.assertIn("<th>%s</th>" % h, html)
|
||||
self.assertEqual(len(re.findall(r"<col(?:>| )", colgroup)), len(heads) + 1,
|
||||
"a column per header, the action column included")
|
||||
# every fixed column carries a width, or the grid is only a wish
|
||||
for cls in re.findall(r"<col class='(\w)'", colgroup):
|
||||
self.assertRegex(html, r"#groups col\.%s\s*\{\s*width:\s*\d+px" % cls)
|
||||
|
||||
def test_details_and_the_requires_note_stay_out_of_the_columns(self):
|
||||
"""Both are long enough to stretch a cell. In a column they would
|
||||
pull one group's grid out of step with the rest: the details get a
|
||||
full-width row, and the note sits under Status, which is sized for
|
||||
it."""
|
||||
html = page.render("0" * 32)
|
||||
self.assertIn("<tr class='detrow'><td colspan='5'>", html)
|
||||
# the status cell carries the status, the unmet note and the row
|
||||
# message; the action cell carries the button and nothing else
|
||||
self.assertIn("<td><div id='st-\"+d+\"'></div><div id='unmet-\"+d+"
|
||||
"\"'></div><div id='note-\"+d+\"'></div></td>", html)
|
||||
self.assertIn("<td><button class='btn btn-sm btn-primary' id='btn-\"+d+\"' "
|
||||
"onclick='startTest(\\\"\"+d+\"\\\")'>Start</button></td>", html)
|
||||
|
||||
def test_page_is_self_contained_ascii(self):
|
||||
html = page.render("0" * 32)
|
||||
self.assertNotIn("__TOKEN__", html)
|
||||
# our own sources stay ASCII (no stray typography in an embedded
|
||||
# page); the vendored Bootstrap carries its own glyphs
|
||||
for name in ("index.html",) + page.CSS_FILES + page.JS_FILES:
|
||||
if name.startswith("vendor/"):
|
||||
continue
|
||||
page.read_ui(name).encode("ascii")
|
||||
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
|
||||
self.assertEqual(stray, [], "control characters in the page source")
|
||||
# nothing fetched from anywhere: the documentation links open a
|
||||
# site, they are not assets
|
||||
for remote in ("<link ", "<script src=", "@import", "url(http", "url(//",
|
||||
'src="http', "src='http", "//cdn"):
|
||||
self.assertNotIn(remote, html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,7 +8,6 @@ fake bench tool. No hardware, no forgectrl."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -18,7 +17,7 @@ import urllib.request
|
||||
|
||||
import helpers
|
||||
from forgetest import bench as bench_mod
|
||||
from forgetest import catalog, server
|
||||
from forgetest import server
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Failed, Runner
|
||||
|
||||
@@ -157,13 +156,6 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("GET", "/", token=False)
|
||||
self.assertEqual(st, 200)
|
||||
self.assertIn(self.token.encode(), d)
|
||||
page = d.decode("utf-8")
|
||||
# one self-contained response: nothing linked, the token once,
|
||||
# the theme attribute the head script sets
|
||||
self.assertEqual(page.count(self.token), 1)
|
||||
self.assertNotIn("<link ", page)
|
||||
self.assertNotIn("<script src=", page)
|
||||
self.assertIn("data-bs-theme", page)
|
||||
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
|
||||
self.assertEqual(st, 403)
|
||||
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
|
||||
@@ -179,23 +171,6 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("GET", "/nope")
|
||||
self.assertEqual(st, 404)
|
||||
|
||||
def test_01c_connection_is_kept_alive(self):
|
||||
"""A poll per second over a fresh TCP connection each time is
|
||||
waste the board does not need to pay."""
|
||||
import http.client
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
try:
|
||||
for _ in range(3):
|
||||
c.request("GET", "/state", headers={"Host": "127.0.0.1:%d" % self.port})
|
||||
r = c.getresponse()
|
||||
r.read()
|
||||
self.assertEqual(r.status, 200)
|
||||
self.assertEqual(r.version, 11)
|
||||
self.assertNotEqual((r.getheader("Connection") or "").lower(), "close")
|
||||
self.assertIsNotNone(r.getheader("Content-Length"))
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_01d_refused_post_does_not_poison_the_connection(self):
|
||||
"""A POST refused before its body is read leaves that body in the
|
||||
socket. On a kept-alive connection the next read would take it for
|
||||
@@ -364,22 +339,6 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("POST", "/abort")
|
||||
state = self.wait_idle()
|
||||
self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED")
|
||||
# a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127)
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "tk"})
|
||||
self.assertEqual(st, 200, d)
|
||||
state = self.wait_idle(timeout=40) # two unreachable-forgectrl settle waits
|
||||
log = "\n".join(state["last_run"]["log"])
|
||||
self.assertIn("takeover: pulse device free", log)
|
||||
self.assertIn("takeover: forgectrl start", log)
|
||||
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
|
||||
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
|
||||
# a scope tool is a takeover too
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
|
||||
self.assertEqual(st, 200, d)
|
||||
state = self.wait_idle(timeout=40)
|
||||
log = "\n".join(state["last_run"]["log"])
|
||||
self.assertIn("takeover: pulse device free", log)
|
||||
self.assertIn("takeover: forgectrl start", log)
|
||||
# bench runs never touched the acceptance log
|
||||
recs = self.log.read()
|
||||
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
|
||||
@@ -406,31 +365,6 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertNotIn("messages", r.state()[0])
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_10_state_is_conditional(self):
|
||||
"""The page polls; an unchanged state must cost a 304, and the
|
||||
ETag must move as soon as the state does.
|
||||
|
||||
Runs last because it invalidates: timestamps are whole seconds, and
|
||||
a PASS stamped in the same second as an invalidate is deliberately
|
||||
not inheritable, so an invalidate early in this class would decide
|
||||
the inheritance the earlier tests are checking.
|
||||
"""
|
||||
st, d, hdrs = self.call_h("GET", "/state")
|
||||
self.assertEqual(st, 200)
|
||||
etag = hdrs.get("ETag")
|
||||
self.assertTrue(etag and etag.startswith('"'), "no ETag on /state")
|
||||
st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
|
||||
self.assertEqual(st, 304)
|
||||
self.assertEqual(hdrs2.get("ETag"), etag)
|
||||
# a stale validator must not be honored
|
||||
st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'})
|
||||
self.assertEqual(st, 200)
|
||||
# and the validator moves with the state
|
||||
self.call("POST", "/invalidate", {"reason": "etag check"})
|
||||
st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
|
||||
self.assertEqual(st, 200)
|
||||
self.assertNotEqual(hdrs3.get("ETag"), etag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,9 +9,9 @@ that client is comes only from gfcloud's one-start markers. Started bare it
|
||||
is the online client with the service's connect-time hunt, and the moves
|
||||
the service sends after the hunt outlast the run. The start is made under
|
||||
the no-hunt marker, and under the offline marker when the takeover found
|
||||
the offline service, which it then proves came back; a miss is a leftover
|
||||
that fails the run. Runs against the fake forgectrl, a fake /proc, and a
|
||||
fake init script that plays the client's start."""
|
||||
the offline service, which it then proves came back. Runs against the
|
||||
fake forgectrl, a fake /proc, and a fake init script that plays the
|
||||
client's start."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -141,38 +141,6 @@ class TakeoverClientTests(unittest.TestCase):
|
||||
self.assertTrue(any("back as the offline service (pid %d)" % self.NEW_PID in l for l in run.lines),
|
||||
run.lines)
|
||||
|
||||
def test_an_online_client_after_the_offline_service_fails_the_run(self):
|
||||
# the client that came up is not listening: an online client, or
|
||||
# a marker the start never saw
|
||||
self._offline_client(self.OLD_PID)
|
||||
real = self._initd
|
||||
|
||||
def lost_marker(service, action, timeout=60):
|
||||
if action == "start" and os.path.exists(baseline.OFFLINE_MARKER):
|
||||
os.remove(baseline.OFFLINE_MARKER)
|
||||
return real(service, action, timeout)
|
||||
hw.initd = lost_marker
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(cap["restart_clients"],
|
||||
[{"where": "takeover end", "expected": "the offline service",
|
||||
"found": "a client that is not the offline service (pid %d)" % self.NEW_PID}])
|
||||
left = baseline.Baseline(run.log)
|
||||
items = []
|
||||
left._preserved(items, cap)
|
||||
self.assertEqual([x.item for x in items], ["cloud client at the takeover end"])
|
||||
self.assertTrue(items[0].action.startswith("not restored"), items[0].action)
|
||||
|
||||
def test_no_client_started_takes_the_markers_down(self):
|
||||
# the gate closed, or standby: nothing read the markers, and a
|
||||
# later start must not come up offline or without its hunt
|
||||
self._offline_client(self.OLD_PID)
|
||||
self.comes_up = "gated"
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt", "gfcloud-offline"])
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
self.assertEqual(len(cap["restart_clients"]), 1)
|
||||
self.assertIn("no cloud client running", cap["restart_clients"][0]["found"])
|
||||
|
||||
def test_an_online_client_comes_back_without_the_hunt(self):
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
|
||||
@@ -180,14 +148,6 @@ class TakeoverClientTests(unittest.TestCase):
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
|
||||
def test_a_stale_offline_socket_file_is_not_the_offline_service(self):
|
||||
# a socket another process left at the path: the online client
|
||||
# found stays online
|
||||
self._socket(99, 7002, baseline.OFFLINE_SOCKET)
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
|
||||
def test_grbl_mode_starts_under_no_marker(self):
|
||||
self.fc.state["mode"] = {"mode": "grbl", "controller": "running", "pid": self.OLD_PID,
|
||||
"motion": "verified"}
|
||||
|
||||
@@ -136,49 +136,6 @@ class TakeoverPositionTests(unittest.TestCase):
|
||||
self.start(t)
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_an_undeclared_frame_change_is_not_judged(self):
|
||||
cap = self.cap()
|
||||
self._new_anchor(2) # a controller start the test did not declare
|
||||
self._pos(self.mm(10), 0, 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
self.assertTrue(any("not in the frame the run began in" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_cloud_mode_is_not_judged(self):
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(10), 0, 0)
|
||||
t, run = self.takeover(cap)
|
||||
t.cloud = True
|
||||
t.position_at_start()
|
||||
self._pos(self.mm(20), 0, 0)
|
||||
t.position_at_end()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_a_bench_tool_is_not_judged(self):
|
||||
t = runner.Takeover(lambda s: None, "bench:tool")
|
||||
self._pos(self.mm(10), 0, 0)
|
||||
t.position_at_start()
|
||||
t.position_at_end() # no run: nothing to judge against, nothing raised
|
||||
|
||||
def test_a_drill_that_leaves_the_head_out_is_recorded_at_the_end(self):
|
||||
cap = self.cap()
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self._pos(self.mm(5), 0, 0) # the drill moved the head and did not bring it back
|
||||
t.position_at_end()
|
||||
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover end"])
|
||||
self.assertEqual(cap["restart_positions"][0]["expected"], [0, 0, 0])
|
||||
|
||||
def test_a_changed_microstep_mode_is_not_judged_at_the_end(self):
|
||||
cap = self.cap()
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self._attr("cnc/x_mode", "8" if hw.sysfs_read("cnc/x_mode") != "8" else "16")
|
||||
self._pos(self.mm(5), 0, 0)
|
||||
t.position_at_end()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_the_post_pass_fails_the_run_and_moves_nothing(self):
|
||||
# setup.check-envelope's case: a declared camera home, the head
|
||||
# left out, the record put back under a takeover, whose restart
|
||||
@@ -228,17 +185,6 @@ class TakeoverPositionTests(unittest.TestCase):
|
||||
self.assertEqual(f.read().strip(), "1")
|
||||
self.assertTrue(any("could not be judged" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_a_declared_rezero_records_the_frame_in_force(self):
|
||||
cap = self.cap()
|
||||
self._new_anchor(5) # the camera home
|
||||
stub = type("Ctx", (), {})()
|
||||
stub.run = FakeRun(cap)
|
||||
stub.log = stub.run.log
|
||||
runner.Context.counters_rezeroed(stub)
|
||||
self.assertEqual(cap["position"], [0, 0, 0])
|
||||
self.assertTrue(cap["rezero_declared"])
|
||||
self.assertEqual(cap["frame"], baseline.counter_frame())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user