Skip to content

Groundwork: mutation pass over the carried modules - #171

Merged
nofuturekid merged 2 commits into
rebuildfrom
fix/170-groundwork-mutants
Nov 13, 2026
Merged

nofuturekid merged 2 commits into
rebuildfrom
fix/170-groundwork-mutants

Conversation

@nofuturekid

@nofuturekid nofuturekid commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #170

One cargo-mutants pass over the carried modules (plan Task 8). It found 202 missed mutants; 100 of them are now killed by new tests, 102 are recorded below with one disposition each. No production code changed (only #[cfg(test)] code and the plan text), and no fault in the carried-over code turned up.

Run record

  • Tool: cargo-mutants 27.1.0.
  • Base: 56731ce (rebuild). Final tree of this pull request: see the head commit.
  • Command (in place, one mutant at a time, as the plan has it; target and output directories outside the repository):
CARGO_BUILD_JOBS=3 timeout 7200 nice -n 19 cargo mutants --package obsbot-device --test-workspace=true \
  --exclude crates/obsbot-device/src/legacy/state.rs --exclude crates/obsbot-device/src/legacy/current_pose.rs \
  --in-place --output <outside the repository> -- -- --skip builders_emit_the_recorded_bytes
  • The byte guard (builders_emit_the_recorded_bytes) is skipped on purpose: it records what the builders emit, so it would kill builder mutants that no real test catches.
  • Scope: 854 mutants (the plan's 847 on 66287b5 plus 3 in family/exchange.rs and 4 in models/tiny2lite/camera.rs from Answers: test the poll in answer (rejected, empty, no answer, read error) #151 and Gimbal: the tilt negation is untested; make it a pure function with a test #153).
  • Run on 56731ce: 854 mutants tested in 17m: 202 missed, 585 caught, 65 unviable, 2 timeouts.
  • Run on the head of this pull request: 854 mutants tested in 26m: 102 missed, 685 caught, 65 unviable, 2 timeouts. git status printed nothing after each run.
  • The 2 timeouts are endless loops the mutant causes (lock_exclusive: the guard Instant::now() < deadline replaced by true; free_view_id: delete !). They count as detected, not as missed.
  • A middle run of the same pass reported three more caught mutants than this one. That was a race in my own new test: two tests of transport/uvc.rs used the same temporary file name. The name now has a counter; the library tests ran 20 times in a row on the final tree without a failure, and the run above is the one on the fixed tree.
  • Test-only code is not mutated: cargo mutants lists no mutant inside #[cfg(test)] items, so scripted.rs and Device::scripted are neither mutated nor counted. The #[cfg(test)] hooks inside Device::xu_get, xu_set, get_ctrl, set_ctrl and ctrl_range are part of those production functions; a mutant that replaces such a function replaces its hook too, and the tests then run against it like against any other.

Dispositions

Disposition Count Meaning
K killed here 100 a new test fails on the mutant (list below)
D needs the device 86
L 0.13 only state 12 a write of the remembered state inside a flow: field deletions, match guards, forget_gimbal
E equivalent 4 reasons below
M waits for a seam 0 see "Named mutants" for one open item outside missed.txt

No D or L mutant is in a function without I/O. D by place:

  • 64: Camera methods whose effect is one or more extension-unit frames (-> Ok(()); nine of them, set_ai_mode, zone_view_go, set_exposure_mode, factory_reset, gimbal, gimbal_rate, gimbal_stop, gimbal_center and recall_preset, also end in a write of the remembered state, but dropping the whole body drops the frames too, so the replay transport of Track A covers them) or a read of the preset list (selectors 0x0c / 0x0d, which the scripted device does not serve)
  • 10: enumeration.rs (card_name, detect: ioctl on a /dev/video* node, a read of /dev)
  • 6: family/streaming.rs (is_streaming, probe, apps_using: REQBUFS ioctl, a read of the real /proc)
  • 6: transport/uvc.rs (Device::ctrl_range: the QUERYCTRL result of a real device)

The K tests use the existing scripted device (Camera flows without a camera) where a read or a check is behind a method, and call pure functions directly. Most D mutants are Camera::set_* methods that replace the whole body with Ok(()); the scripted device drops the frames they write, so only a replay transport can show that the frame went out. That is Track A's job and is not started here.

Equivalent mutants:

  • models/tiny2lite/camera.rs:570:36: replace + with - in Camera::live_pose_and_zoom: x + 0.0 and x - 0.0 differ only for x == -0.0, and the tilt comes from LivePose::decode, which already adds 0.0 (killed there by a test).
  • models/tiny2lite/commands.rs:198:28: replace | with ^ in default_work_mode: main << 4 has the low nibble clear and every AiMode sub-mode is 0-4, so | and ^ give the same byte.
  • models/tiny2lite/presets.rs:407:32: replace | with ^ in decode_base64: buffer << 6 has its low six bits clear and a base64 value is below 64, so | and ^ give the same word.
  • transport/uvc.rs:91:60: replace < with <= in lock_exclusive: < against <= differs only when Instant::now() equals the deadline exactly, which no input controls; the effect would be one more 20 ms retry.

Named mutants (spec 10.2)

  • delete match arm 0x89 in parse_answer (family/frame.rs:120): caught.
  • Every delete - in the angle conversions is caught: LivePose::decode (1), HandArea::decode (2), tilt_to_pitch_range (2), pitch_to_tilt_range (2), and tilt_to_pitch (1), which is where the gimbal's tilt negation lives since Gimbal: the tilt negation is untested; make it a pure function with a test #153 (the plan's Camera::gimbal pattern is corrected in the plan document).
  • A rejected answer handled as stale (hand-made: the Answer::Rejected arm of poll_answer changed to => {}): a_rejected_answer_returns_the_cameras_code_at_the_first_poll fails (test result: FAILED. 170 passed; 1 failed). Reverted; git status clean.
  • A rejected tracking query failing the whole read (hand-made: | Error::Rejected(..) removed from the tolerated errors of full_status_with): a_rejected_tracking_query_gives_tracking_null_and_the_rest_of_the_status fails (170 passed; 1 failed). Reverted; git status clean.
  • Open (disposition M): the call site of tilt_to_pitch in Camera::gimbal. A hand-made mutant let tilt_wire = tilt; leaves all 171 library tests green, because Camera::gimbal ends in a write of the remembered state and no test calls it. The negation itself is covered (delete - in tilt_to_pitch is caught and tilt_to_pitch has its own tests); the wiring needs a seam for the state path or the replay transport. Listed as an open item under "After the tasks" in the plan document (changed in this pull request); the coordinator may also open an issue for it. replace Camera::gimbal -> Result<()> with Ok(()) is in the table below as L.

Changes

  • Tests only, in the existing test modules of family/frame.rs, family/exchange.rs, models/tiny2lite/{commands,presets,status}.rs, transport/uvc.rs and legacy/full_status.rs, and a new module scripted_camera_tests in models/tiny2lite/camera.rs (the same pattern as in legacy/full_status.rs). 42 new test names, none removed or renamed.
  • scripted.rs (test-only): the doc comment of Scripted now says only what the double does (it answers an unknown query with type 0x89, code -1, so a test does not wait out the poll; no statement about the camera). New: the table records set_ctrl calls, a control can have its own range, and camera(script) builds a Camera on it.
  • transport/uvc.rs: four lines under #[cfg(test)] in Device::set_ctrl (the same hook the other methods have). The production build is unchanged.
  • The plan document: the Step 5 pattern names tilt_to_pitch, the expected result of Step 5 and the M disposition say what is expected now that the seams of P3 are on rebuild, and Step 2 says how the run treats #[cfg(test)] code. The P5 sentence about the shellcheck gate already says it covers every script (commit 56731ce).
  • Two of the 42 tests are extra pins written alongside the K tests, not the killing test of a listed mutant: an_entry_reads_each_pose_word_from_its_own_bytes (offsets of parse_entry) and one_byte_past_the_data_section_is_refused (the 44-byte limit of the frame parser).
  • No CHANGELOG line (tests and plan text only).

Verification (no camera)

  • cargo fmt --check, cargo clippy --workspace --all-targets --locked -- -D warnings, shellcheck scripts/*.sh scripts/groundwork/*.sh, actionlint .github/workflows/*.yml: no output.
  • cargo test --workspace --locked: 171 library tests, byte_guard 1, cli_surface 2 and the others pass; the library tests were run 20 times in a row without a failure.
  • G1: test-names.sh lists 157 names on the base and 199 on the head; comm -23 (lost) prints nothing, comm -13 prints the 42 new names.
  • G2, G3: tests/byte_guard.rs, tests/cli_surface.rs and their fixtures are not touched and pass.
  • G6: move-check.sh 56731ce HEAD prints 649 added lines and 4 removed ones: the two doc-comment lines and the two default-range lines of the scripted device. All are #[cfg(test)] code; no line of the production build changed.
  • Each K mutant: every one of the 100 is missed in the run on the base and caught in the run on the head, and the list below gives the test that fails on it (taken from the log of the run).
  • Tests that can fail: besides the run, the two hand-made mutants above turn a test red and are reverted.

One caveat for the reader: two_offset_frames_are_a_gap_apart_and_one_frame_needs_no_gap measures time. The lower bound (a pair takes at least the 100 ms gap) cannot flake; the upper bound (a single frame returns in under 100 ms) has a margin of several orders of magnitude over a scripted call, but a stalled machine could still trip it.

Missed mutants with their disposition (102)
  • enumeration.rs:39:5: replace card_name -> Option<(String, u32)> with None — D
  • enumeration.rs:39:5: replace card_name -> Option<(String, u32)> with Some((String::new(), 0)) — D
  • enumeration.rs:39:5: replace card_name -> Option<(String, u32)> with Some((String::new(), 1)) — D
  • enumeration.rs:39:5: replace card_name -> Option<(String, u32)> with Some(("xyzzy".into(), 0)) — D
  • enumeration.rs:39:5: replace card_name -> Option<(String, u32)> with Some(("xyzzy".into(), 1)) — D
  • enumeration.rs:44:26: replace == with != in card_name — D
  • enumeration.rs:55:5: replace detect -> Option<Detected> with None — D
  • enumeration.rs:78:21: replace & with | in detect — D
  • enumeration.rs:78:21: replace & with ^ in detect — D
  • enumeration.rs:78:45: replace == with != in detect — D
  • family/exchange.rs:40:9: replace Camera::short -> Result<()> with Ok(()) — D
  • family/streaming.rs:63:5: replace is_streaming -> bool with false — D
  • family/streaming.rs:76:9: delete field type_ from struct V4l2RequestBuffers expression in probe — D
  • family/streaming.rs:77:9: delete field memory from struct V4l2RequestBuffers expression in probe — D
  • family/streaming.rs:113:5: replace apps_using -> Vec<String> with vec![] — D
  • family/streaming.rs:113:5: replace apps_using -> Vec<String> with vec![String::new()] — D
  • family/streaming.rs:113:5: replace apps_using -> Vec<String> with vec!["xyzzy".into()] — D
  • models/tiny2lite/camera.rs:424:9: replace Camera::sleep -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:427:9: replace Camera::set_ai_mode -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:428:17: replace != with == in Camera::set_ai_mode — L
  • models/tiny2lite/camera.rs:431:17: delete field gimbal from struct State expression in Camera::set_ai_mode — L
  • models/tiny2lite/camera.rs:439:9: replace Camera::set_tracking_speed -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:442:9: replace Camera::set_speed_mode -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:445:9: replace Camera::set_auto_zoom -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:448:9: replace Camera::set_framing_size -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:451:9: replace Camera::set_auto_zoom_speed -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:454:9: replace Camera::set_part_mode -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:478:9: replace Camera::set_hdr -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:481:9: replace Camera::set_auto_sleep -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:491:9: replace Camera::set_sleep_background -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:494:9: replace Camera::set_sleep_background_mirror -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:497:9: replace Camera::set_led -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:500:9: replace Camera::set_led_blink -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:510:9: replace Camera::set_default_work_mode -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:513:9: replace Camera::set_field_of_view -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:516:9: replace Camera::set_mirror -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:519:9: replace Camera::set_invert -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:522:9: replace Camera::set_mic_in_sleep -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:525:9: replace Camera::set_noise_reduction -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:528:9: replace Camera::set_auto_gain -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:533:9: replace Camera::set_audio_volume -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:536:9: replace Camera::set_pickup_distance -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:539:9: replace Camera::set_microphone -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:542:9: replace Camera::set_gesture -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:545:9: replace Camera::set_gesture_zoom_ratio -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:548:9: replace Camera::set_hand_side -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:551:9: replace Camera::set_zone_tracking -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:557:9: replace Camera::set_zone_area -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:570:36: replace + with - in Camera::live_pose_and_zoom — E
  • models/tiny2lite/camera.rs:592:9: replace Camera::set_hand_area -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:601:9: replace Camera::hand_area_reset -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:609:9: replace Camera::set_hand_pan -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:613:9: replace Camera::set_hand_tilt -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:617:9: replace Camera::set_hand_return_after -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:622:9: replace Camera::set_hand_gimbal -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:647:9: replace Camera::rename_zone_view -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:651:9: replace Camera::delete_zone_view -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:657:9: replace Camera::zone_view_go -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:659:13: delete field gimbal from struct State expression in Camera::zone_view_go — L
  • models/tiny2lite/camera.rs:667:9: replace Camera::hand_return_go -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:672:9: replace Camera::hand_return_reset -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:677:9: replace Camera::zone_return_go -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:682:9: replace Camera::startup_go -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:686:9: replace Camera::set_zone_follow -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:691:9: replace Camera::set_autofocus_mode -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:696:9: replace Camera::set_autofocus_select -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:702:9: replace Camera::focus_point -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:733:9: replace Camera::set_startup -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:747:9: replace Camera::set_startup_ai -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:756:61: replace == with != in Camera::set_startup_preset — D
  • models/tiny2lite/camera.rs:775:9: replace Camera::set_exposure_mode -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:778:13: delete field exposure_mode from struct State expression in Camera::set_exposure_mode — L
  • models/tiny2lite/camera.rs:801:9: replace Camera::factory_reset -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:813:9: replace Camera::gimbal -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:829:9: replace Camera::gimbal_rate -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:838:9: replace Camera::gimbal_stop -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:845:9: replace Camera::gimbal_center -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:853:9: replace Camera::forget_gimbal -> Result<()> with Ok(()) — L
  • models/tiny2lite/camera.rs:854:13: delete field gimbal from struct State expression in Camera::forget_gimbal — L
  • models/tiny2lite/camera.rs:862:9: replace Camera::zoom_to -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:867:9: replace Camera::zoom_stop -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:922:9: replace Camera::presets -> Result<Vec<Preset>> with Ok(vec![]) — D
  • models/tiny2lite/camera.rs:948:60: replace == with != in Camera::save_preset — D
  • models/tiny2lite/camera.rs:956:38: delete ! in Camera::save_preset — D
  • models/tiny2lite/camera.rs:964:9: replace Camera::rename_preset -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:968:9: replace Camera::delete_preset -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:973:9: replace Camera::recall_preset -> Result<()> with Ok(()) — D
  • models/tiny2lite/camera.rs:974:50: replace == with != in Camera::recall_preset — L
  • models/tiny2lite/camera.rs:975:66: replace == with != in Camera::recall_preset — L
  • models/tiny2lite/camera.rs:977:24: replace match guard static_mode with true in Camera::recall_preset — L
  • models/tiny2lite/camera.rs:977:24: replace match guard static_mode with false in Camera::recall_preset — L
  • models/tiny2lite/camera.rs:978:17: delete field gimbal from struct State expression in Camera::recall_preset — L
  • models/tiny2lite/camera.rs:984:17: delete field gimbal from struct State expression in Camera::recall_preset — L
  • models/tiny2lite/commands.rs:198:28: replace | with ^ in default_work_mode — E
  • models/tiny2lite/presets.rs:407:32: replace | with ^ in decode_base64 — E
  • transport/uvc.rs:91:60: replace < with <= in lock_exclusive — E
  • transport/uvc.rs:225:13: delete field id from struct V4l2QueryCtrl expression in Device::ctrl_range — D
  • transport/uvc.rs:229:22: replace match guard q.flags & V4L2_CTRL_FLAG_DISABLED == 0 with true in Device::ctrl_range — D
  • transport/uvc.rs:229:22: replace match guard q.flags & V4L2_CTRL_FLAG_DISABLED == 0 with false in Device::ctrl_range — D
  • transport/uvc.rs:229:30: replace & with | in Device::ctrl_range — D
  • transport/uvc.rs:229:30: replace & with ^ in Device::ctrl_range — D
  • transport/uvc.rs:229:56: replace == with != in Device::ctrl_range — D
Killed mutants and the test that fails on each (100)
  • family/exchange.rs:22:5: replace sequence -> &'static Sequence with Box::leak(Box::new(Default::default())) → all_cameras_of_a_process_share_one_sequence
  • family/exchange.rs:48:9: replace Camera::pair -> Result<()> with Ok(()) → a_pair_fails_when_either_frame_cannot_be_sent, a_pair_is_sent_with_a_gap_between_its_frames
  • family/frame.rs:13:40: replace - with + → a_frame_holds_exactly_44_bytes_of_data
  • family/frame.rs:101:30: replace && with || in error_code → a_rejection_needs_a_16_byte_header_and_the_extension_tag
  • family/frame.rs:113:20: replace < with == in parse_answer → a_reply_must_hold_both_headers_to_be_an_answer
  • family/frame.rs:113:20: replace < with <= in parse_answer → a_reply_must_hold_both_headers_to_be_an_answer
  • family/frame.rs:113:34: replace || with && in parse_answer → a_reply_must_hold_both_headers_to_be_an_answer
  • legacy/full_status.rs:202:40: replace == with != in Camera::full_status_with → the_target_is_asked_only_while_ai_tracking_is_on
  • models/tiny2lite/camera.rs:67:48: replace + with - in LivePose::decode → a_live_pose_at_pitch_zero_has_a_positive_zero_tilt
  • models/tiny2lite/camera.rs:132:34: replace + with - in HandArea::decode → hand_area_pitch_limits_of_zero_read_as_positive_zero_tilts
  • models/tiny2lite/camera.rs:133:34: replace + with - in HandArea::decode → hand_area_pitch_limits_of_zero_read_as_positive_zero_tilts
  • models/tiny2lite/camera.rs:160:31: replace + with - in pitch_to_tilt_range → a_zero_pitch_minimum_reads_as_a_positive_zero_tilt_maximum
  • models/tiny2lite/camera.rs:265:9: replace Camera::target -> Result<Option<Target>> with Ok(None) → the_target_is_asked_only_while_ai_tracking_is_on, the_tracking_target_is_read_from_query_0x01c4
  • models/tiny2lite/camera.rs:279:9: replace Camera::image_controls -> Result<BTreeMap<String, ImageControl>> with Ok(BTreeMap::new()) → image_controls_lists_the_controls_the_camera_has_with_their_values
  • models/tiny2lite/camera.rs:296:9: replace Camera::query_f32 -> Result<f32> with Ok(0.0) → a_hand_return_time_is_zero_or_more_else_it_is_not_shown, the_framing_options_are_read_from_their_queries, the_hand_tracking_is_read_from_its_queries (+1)
  • models/tiny2lite/camera.rs:296:9: replace Camera::query_f32 -> Result<f32> with Ok(1.0) → a_hand_return_time_is_zero_or_more_else_it_is_not_shown, the_framing_options_are_read_from_their_queries, the_hand_tracking_is_read_from_its_queries (+1)
  • models/tiny2lite/camera.rs:296:9: replace Camera::query_f32 -> Result<f32> with Ok(-1.0) → a_hand_return_time_is_zero_or_more_else_it_is_not_shown, the_framing_options_are_read_from_their_queries, the_hand_tracking_is_read_from_its_queries (+1)
  • models/tiny2lite/camera.rs:320:68: replace != with == in Camera::zone → the_zone_is_read_from_its_queries_in_obsbot4linux_angles
  • models/tiny2lite/camera.rs:339:69: replace >= with < in Camera::hand → a_hand_return_time_is_zero_or_more_else_it_is_not_shown, the_hand_tracking_is_read_from_its_queries
  • models/tiny2lite/camera.rs:347:9: replace Camera::zone_views -> Result<Vec<ZoneView>> with Ok(vec![]) → the_zone_is_read_from_its_queries_in_obsbot4linux_angles
  • models/tiny2lite/camera.rs:397:9: replace Camera::query_u8 -> Result<u8> with Ok(0) → the_framing_options_are_read_from_their_queries, the_initial_state_ai_byte_is_the_first_byte_of_0x3e84
  • models/tiny2lite/camera.rs:397:9: replace Camera::query_u8 -> Result<u8> with Ok(1) → the_framing_options_are_read_from_their_queries, the_initial_state_ai_byte_is_the_first_byte_of_0x3e84
  • models/tiny2lite/camera.rs:410:38: replace != with == in Camera::framing → the_framing_options_are_read_from_their_queries
  • models/tiny2lite/camera.rs:458:9: replace Camera::set_framing_offset -> Result<()> with Ok(()) → the_framing_offset_needs_an_axis, two_offset_frames_are_a_gap_apart_and_one_frame_needs_no_gap
  • models/tiny2lite/camera.rs:458:24: replace && with || in Camera::set_framing_offset → the_framing_offset_needs_an_axis, two_offset_frames_are_a_gap_apart_and_one_frame_needs_no_gap
  • models/tiny2lite/camera.rs:469:16: delete ! in Camera::set_framing_offset → two_offset_frames_are_a_gap_apart_and_one_frame_needs_no_gap
  • models/tiny2lite/camera.rs:504:9: replace Camera::set_wake_zoom -> Result<u8> with Ok(0) → the_wake_up_zoom_is_reported_as_the_percent_that_was_stored
  • models/tiny2lite/camera.rs:504:9: replace Camera::set_wake_zoom -> Result<u8> with Ok(1) → the_wake_up_zoom_is_reported_as_the_percent_that_was_stored
  • models/tiny2lite/camera.rs:570:36: replace + with * in Camera::live_pose_and_zoom → the_current_pose_is_the_live_pose_and_the_zoom_with_the_zoom_kept_in_range
  • models/tiny2lite/camera.rs:707:9: replace Camera::set_autofocus -> Result<()> with Ok(()) → autofocus_is_the_continuous_autofocus_control
  • models/tiny2lite/camera.rs:712:9: replace Camera::set_focus -> Result<()> with Ok(()) → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:18: replace < with == in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:18: replace < with > in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:18: replace < with <= in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:26: replace || with && in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:35: replace > with == in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:35: replace > with < in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:716:35: replace > with >= in Camera::set_focus → manual_focus_turns_autofocus_off_and_stays_in_range
  • models/tiny2lite/camera.rs:725:9: replace Camera::startup_ai_byte -> Result<u8> with Ok(0) → the_initial_state_ai_byte_is_the_first_byte_of_0x3e84
  • models/tiny2lite/camera.rs:725:9: replace Camera::startup_ai_byte -> Result<u8> with Ok(1) → the_initial_state_ai_byte_is_the_first_byte_of_0x3e84
  • models/tiny2lite/camera.rs:791:9: replace Camera::set_manual_exposure -> Result<()> with Ok(()) → manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:872:9: replace Camera::set_zoom -> Result<()> with Ok(()) → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:872:12: delete ! in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:879:29: replace - with + in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:879:29: replace - with / in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:879:36: replace / with % in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:879:36: replace / with * in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:879:48: replace - with + in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:879:48: replace - with / in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:880:27: replace + with - in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:880:27: replace + with * in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:880:36: replace * with + in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:880:36: replace * with / in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:880:45: replace - with + in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:880:45: replace - with / in Camera::set_zoom → zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • models/tiny2lite/camera.rs:887:9: replace Camera::image_id -> Result<u32> with Ok(0) → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, image_names_map_to_their_v4l2_ids (+1)
  • models/tiny2lite/camera.rs:887:9: replace Camera::image_id -> Result<u32> with Ok(1) → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, image_names_map_to_their_v4l2_ids (+1)
  • models/tiny2lite/camera.rs:889:31: replace == with != in Camera::image_id → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, image_names_map_to_their_v4l2_ids (+1)
  • models/tiny2lite/camera.rs:896:9: replace Camera::image_id_in_range -> Result<u32> with Ok(0) → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:896:9: replace Camera::image_id_in_range -> Result<u32> with Ok(1) → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:901:18: replace < with == in Camera::image_id_in_range → an_image_value_must_be_inside_the_range_of_the_control
  • models/tiny2lite/camera.rs:901:18: replace < with > in Camera::image_id_in_range → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:901:18: replace < with <= in Camera::image_id_in_range → an_image_value_must_be_inside_the_range_of_the_control
  • models/tiny2lite/camera.rs:901:26: replace || with && in Camera::image_id_in_range → an_image_value_must_be_inside_the_range_of_the_control
  • models/tiny2lite/camera.rs:901:35: replace > with == in Camera::image_id_in_range → an_image_value_must_be_inside_the_range_of_the_control, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:901:35: replace > with < in Camera::image_id_in_range → a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:901:35: replace > with >= in Camera::image_id_in_range → an_image_value_must_be_inside_the_range_of_the_control, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:908:9: replace Camera::set_image -> Result<()> with Ok(()) → a_shutter_time_needs_manual_exposure, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:910:17: replace == with != in Camera::set_image → a_shutter_time_needs_manual_exposure
  • models/tiny2lite/camera.rs:910:36: replace && with || in Camera::set_image → a_shutter_time_needs_manual_exposure, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/camera.rs:910:73: replace != with == in Camera::set_image → a_shutter_time_needs_manual_exposure, manual_exposure_switches_first_and_then_sets_time_and_gain
  • models/tiny2lite/commands.rs:170:19: replace > with >= in led → a_documented_limit_is_accepted_and_one_past_it_is_not
  • models/tiny2lite/commands.rs:182:16: replace > with >= in wake_zoom → a_documented_limit_is_accepted_and_one_past_it_is_not, the_wake_up_zoom_is_reported_as_the_percent_that_was_stored
  • models/tiny2lite/commands.rs:312:48: replace < with <= in area → an_area_needs_its_minimum_below_its_maximum
  • models/tiny2lite/commands.rs:347:40: replace < with <= in hand_return_after → a_documented_limit_is_accepted_and_one_past_it_is_not
  • models/tiny2lite/commands.rs:383:15: replace > with >= in audio_volume → a_documented_limit_is_accepted_and_one_past_it_is_not
  • models/tiny2lite/commands.rs:495:57: replace + with - in gimbal_rate → a_rate_of_zero_sends_twelve_zero_bytes_whatever_mirror_and_invert_are
  • models/tiny2lite/commands.rs:499:28: replace + with - in gimbal_rate → a_rate_of_zero_sends_twelve_zero_bytes_whatever_mirror_and_invert_are
  • models/tiny2lite/commands.rs:525:14: replace < with <= in zoom_to → a_documented_limit_is_accepted_and_one_past_it_is_not
  • models/tiny2lite/presets.rs:288:9: delete match arm 2 in startup_ai_mode → startup_ai_mode_names_the_documented_bytes
  • models/tiny2lite/presets.rs:290:9: delete match arm 4 in startup_ai_mode → startup_ai_mode_names_the_documented_bytes
  • models/tiny2lite/presets.rs:340:20: replace < with <= in StartupActions::decode → startup_actions_need_the_24_bytes_up_to_the_hue
  • models/tiny2lite/presets.rs:367:24: replace < with <= in parse_slots → a_slot_index_outside_the_slots_is_not_listed
  • models/tiny2lite/presets.rs:389:34: replace + with - in parse_entry → a_preset_at_tilt_zero_reads_as_positive_zero
  • models/tiny2lite/presets.rs:399:9: delete match arm b'0'..= b'9' in decode_base64 → base64_names_use_the_standard_alphabet
  • models/tiny2lite/presets.rs:399:31: replace - with + in decode_base64 → base64_names_use_the_standard_alphabet
  • models/tiny2lite/presets.rs:399:31: replace - with / in decode_base64 → base64_names_use_the_standard_alphabet
  • models/tiny2lite/presets.rs:399:38: replace + with - in decode_base64 → base64_names_use_the_standard_alphabet
  • models/tiny2lite/presets.rs:400:9: delete match arm b'+' in decode_base64 → base64_names_use_the_standard_alphabet
  • models/tiny2lite/presets.rs:401:9: delete match arm b'/' in decode_base64 → base64_names_use_the_standard_alphabet
  • models/tiny2lite/status.rs:28:13: delete match arm 0 in FovReading::from_u8 → field_of_view_reading_names_every_value_the_camera_reports
  • models/tiny2lite/status.rs:54:13: delete match arm 0 in AntiFlicker::from_u8 → anti_flicker_names_every_value_of_power_line_frequency
  • transport/uvc.rs:154:9: replace Device::xu -> Result<()> with Ok(()) → a_payload_longer_than_the_buffer_is_refused_before_any_ioctl, a_refused_ioctl_is_an_error_not_a_default_value
  • transport/uvc.rs:166:9: replace Device::xu_len -> Result<usize> with Ok(0) → a_payload_longer_than_the_buffer_is_refused_before_any_ioctl, a_refused_ioctl_is_an_error_not_a_default_value
  • transport/uvc.rs:166:9: replace Device::xu_len -> Result<usize> with Ok(1) → a_payload_longer_than_the_buffer_is_refused_before_any_ioctl, a_refused_ioctl_is_an_error_not_a_default_value
  • transport/uvc.rs:189:26: replace > with == in Device::xu_set → a_payload_longer_than_the_buffer_is_refused_before_any_ioctl
  • transport/uvc.rs:189:26: replace > with < in Device::xu_set → a_payload_longer_than_the_buffer_is_refused_before_any_ioctl
  • transport/uvc.rs:189:26: replace > with >= in Device::xu_set → a_payload_longer_than_the_buffer_is_refused_before_any_ioctl
  • transport/uvc.rs:209:9: replace Device::set_ctrl -> Result<()> with Ok(()) → a_refused_ioctl_is_an_error_not_a_default_value, a_shutter_time_needs_manual_exposure, autofocus_is_the_continuous_autofocus_control (+3)
  • transport/uvc.rs:220:9: replace Device::ctrl_range -> Result<Option<CtrlRange>> with Ok(None) → a_refused_ioctl_is_an_error_not_a_default_value, a_shutter_time_needs_manual_exposure, an_image_value_must_be_inside_the_range_of_the_control (+4)
New test names (42)
  • angle_conversion_tests::a_live_pose_at_pitch_zero_has_a_positive_zero_tilt
  • angle_conversion_tests::a_zero_pitch_minimum_reads_as_a_positive_zero_tilt_maximum
  • angle_conversion_tests::hand_area_pitch_limits_of_zero_read_as_positive_zero_tilts
  • scripted_camera_tests::a_hand_return_time_is_zero_or_more_else_it_is_not_shown
  • scripted_camera_tests::a_shutter_time_needs_manual_exposure
  • scripted_camera_tests::an_image_value_must_be_inside_the_range_of_the_control
  • scripted_camera_tests::autofocus_is_the_continuous_autofocus_control
  • scripted_camera_tests::image_controls_lists_the_controls_the_camera_has_with_their_values
  • scripted_camera_tests::image_names_map_to_their_v4l2_ids
  • scripted_camera_tests::manual_exposure_switches_first_and_then_sets_time_and_gain
  • scripted_camera_tests::manual_focus_turns_autofocus_off_and_stays_in_range
  • scripted_camera_tests::the_current_pose_is_the_live_pose_and_the_zoom_with_the_zoom_kept_in_range
  • scripted_camera_tests::the_framing_offset_needs_an_axis
  • scripted_camera_tests::the_framing_options_are_read_from_their_queries
  • scripted_camera_tests::the_hand_tracking_is_read_from_its_queries
  • scripted_camera_tests::the_initial_state_ai_byte_is_the_first_byte_of_0x3e84
  • scripted_camera_tests::the_target_is_asked_only_while_ai_tracking_is_on
  • scripted_camera_tests::the_tracking_target_is_read_from_query_0x01c4
  • scripted_camera_tests::the_wake_up_zoom_is_reported_as_the_percent_that_was_stored
  • scripted_camera_tests::the_zone_is_read_from_its_queries_in_obsbot4linux_angles
  • scripted_camera_tests::two_offset_frames_are_a_gap_apart_and_one_frame_needs_no_gap
  • scripted_camera_tests::zoom_maps_the_factor_onto_the_range_of_the_zoom_control
  • tests::a_documented_limit_is_accepted_and_one_past_it_is_not
  • tests::a_frame_holds_exactly_44_bytes_of_data
  • tests::a_pair_fails_when_either_frame_cannot_be_sent
  • tests::a_pair_is_sent_with_a_gap_between_its_frames
  • tests::a_payload_longer_than_the_buffer_is_refused_before_any_ioctl
  • tests::a_preset_at_tilt_zero_reads_as_positive_zero
  • tests::a_rate_of_zero_sends_twelve_zero_bytes_whatever_mirror_and_invert_are
  • tests::a_refused_ioctl_is_an_error_not_a_default_value
  • tests::a_rejection_needs_a_16_byte_header_and_the_extension_tag
  • tests::a_reply_must_hold_both_headers_to_be_an_answer
  • tests::a_slot_index_outside_the_slots_is_not_listed
  • tests::all_cameras_of_a_process_share_one_sequence
  • tests::an_area_needs_its_minimum_below_its_maximum
  • tests::an_entry_reads_each_pose_word_from_its_own_bytes
  • tests::anti_flicker_names_every_value_of_power_line_frequency
  • tests::base64_names_use_the_standard_alphabet
  • tests::field_of_view_reading_names_every_value_the_camera_reports
  • tests::one_byte_past_the_data_section_is_refused
  • tests::startup_actions_need_the_24_bytes_up_to_the_hue
  • tests::startup_ai_mode_names_the_documented_bytes

🤖 Generated with Claude Code

One cargo-mutants 27.1.0 pass over obsbot-device (without the 0.13-only state and pose
modules) left 202 missed mutants. These tests kill the ones that sit in code without I/O or
behind the scripted device: frame length limits and the rejection layout, the pair gap, the
sequence counter, builder limits and the sign of a zero rate, preset and status decoders,
the Camera reads and checks, and how the device handles a refused ioctl.

The scripted device also records set_ctrl calls and can give a control its own range; its doc
comment now describes only what the test double does. The plan names the tilt negation by its
function tilt_to_pitch and says how the run treats cfg(test) code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@nofuturekid nofuturekid added the enhancement New feature or request label Nov 13, 2026
The scripted device sits behind five methods since set_ctrl was added; the module
doc said four. The plan now lists the open call-site item of tilt_to_pitch under
"After the tasks".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@nofuturekid
nofuturekid merged commit 3252cb7 into rebuild Nov 13, 2026
6 checks passed
@nofuturekid
nofuturekid deleted the fix/170-groundwork-mutants branch November 13, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant