Files
nixos/hosts/yarn/forza-trigger/test_forza_trigger.py
Simon Gardling 5798caef37 forza-trigger: hysteresis on is_race_on (real fix for between-race clicks)
Strace post-deploy showed the daemon flipping every other packet between
in-race state (mode 0x21 FEEDBACK + LED green) and out-of-race state
(mode 0x05 OFF + LED black). 8 writes, 8 transitions in 5s — every
HID OUT report a state change.

Root cause: FH5 emits packets where the is_race_on field alternates
True/False at packet rate during menu/loading/transition states.
Cosmii's RPM_ACCUMULATOR debounce only handles the 'flag stuck True
when we're really in a menu' case (quirk #1); it does nothing for
'flag flipping at packet rate' (quirk #2).

Fix: split the read from the hysteresis. is_race_on now returns the
raw flag with quirk #1 applied. commit_in_race applies a packet-count
hysteresis (IN_RACE_HYSTERESIS_PACKETS = 30 ≈ 0.5s at 60Hz) — only
after N consecutive packets of the new value does the committed
in-race state flip. Alternation just keeps the pending counter
oscillating near 0; it never reaches threshold and the run-loop sees
a stable state.

Architecture: hysteresis in a separate function (not in is_race_on)
because the run-loop must update state.last_in_race AFTER side
effects, not before — otherwise the elif transition-detection
breaks. is_race_on stays pure read; commit_in_race is the gatekeeper;
run-loop sets state.last_in_race once at end of packet handling.

Tests grew 54→58. New TestCommitInRace covers:
  - stable input (no pending growth)
  - worst-case alternation (never commits)
  - N consecutive packets commit
  - matching raw resets pending counter

TestIsRaceOn renamed to reflect new (narrower) responsibility.
2026-05-07 15:11:35 -04:00

802 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Behavioral tests for forza_trigger.py.
Pure-function tests + integration tests using a FakeController that records
every effect call. No hardware required.
Run: `python -m unittest discover -s hosts/yarn/forza-trigger -p 'test_*.py'`
or via the nix derivation in default.nix (runs at build time).
"""
import math
import os
import sys
import unittest
from dataclasses import dataclass, field
from typing import Any, Tuple
# Disable lightbar by default for triggers-only tests; specific lightbar tests
# re-enable by mutating module attributes.
os.environ.setdefault("FORZA_LIGHTBAR", "0")
import forza_trigger as ft # noqa: E402
# --- Fakes ------------------------------------------------------------------
class FakeEffect:
def __init__(self, log: list, side: str):
self.log = log
self.side = side
def off(self):
self.log.append((self.side, "off"))
def feedback(self, start_position=0, strength=0):
self.log.append((self.side, "feedback", start_position, strength))
def vibration(self, start_position=0, amplitude=0, frequency=0):
self.log.append((self.side, "vibration", start_position, amplitude, frequency))
class FakeTrigger:
def __init__(self, log: list, side: str):
self.effect = FakeEffect(log, side)
class FakeLightbar:
def __init__(self, log: list):
self.log = log
def set_color(self, r, g, b):
self.log.append(("lightbar", "set_color", r, g, b))
class FakeController:
def __init__(self):
self.calls: list = []
self.left_trigger = FakeTrigger(self.calls, "L2")
self.right_trigger = FakeTrigger(self.calls, "R2")
self.lightbar = FakeLightbar(self.calls)
@dataclass
class FakePacket:
"""Shape mirrors fdp.ForzaDataPacket. Only the fields handlers read are
populated; the rest stay 0.
"""
accel: float = 0.0
brake: float = 0.0
is_race_on: float = 1.0
current_engine_rpm: float = 3000.0
engine_idle_rpm: float = 800.0
engine_max_rpm: float = 8000.0
power: float = 100.0
acceleration_x: float = 0.0
acceleration_y: float = 0.0
acceleration_z: float = 0.0
tire_combined_slip_FL: float = 0.0
tire_combined_slip_FR: float = 0.0
tire_combined_slip_RL: float = 0.0
tire_combined_slip_RR: float = 0.0
car_class: float = 0.0
car_performance_index: float = 0.0
# --- Pure-function tests ----------------------------------------------------
class TestMap(unittest.TestCase):
def test_endpoints(self):
self.assertEqual(ft._map(0, 0, 1, 0, 100), 0)
self.assertEqual(ft._map(1, 0, 1, 0, 100), 100)
def test_midpoint(self):
self.assertEqual(ft._map(0.5, 0, 1, 0, 100), 50)
def test_clamps_low(self):
self.assertEqual(ft._map(-5, 0, 10, 0, 100), 0)
def test_clamps_high(self):
self.assertEqual(ft._map(50, 0, 10, 0, 100), 100)
def test_zero_input_range(self):
# Degenerate input range — must not divide by zero.
self.assertEqual(ft._map(5, 5, 5, 0, 100), 0)
class TestEWMA(unittest.TestCase):
def test_alpha_one_is_instant(self):
# α=1.0 → output exactly equals the input (no smoothing).
self.assertEqual(ft._ewma(10, 5, 1.0), 10)
def test_alpha_zero_is_frozen(self):
# α=0.0 → output exactly equals the previous value (no update).
self.assertEqual(ft._ewma(10, 5, 0.0), 5)
def test_alpha_half(self):
self.assertEqual(ft._ewma(10, 6, 0.5), 8)
class TestSafeField(unittest.TestCase):
def test_missing_attribute_returns_default(self):
pkt = FakePacket()
self.assertEqual(ft._safe_field(pkt, "nonexistent_field", 42.0), 42.0)
def test_nan_returns_default(self):
pkt = FakePacket(accel=math.nan)
self.assertEqual(ft._safe_field(pkt, "accel", 7.0), 7.0)
def test_inf_returns_default(self):
pkt = FakePacket(accel=math.inf)
self.assertEqual(ft._safe_field(pkt, "accel", 0.0), 0.0)
def test_normal_value(self):
pkt = FakePacket(accel=128.0)
self.assertEqual(ft._safe_field(pkt, "accel"), 128.0)
class TestCombinedSlip(unittest.TestCase):
def test_arithmetic_mean(self):
pkt = FakePacket(
tire_combined_slip_FL=0.4,
tire_combined_slip_FR=0.6,
tire_combined_slip_RL=0.2,
tire_combined_slip_RR=0.8,
)
all_, front, rear = ft._combined_slip(pkt)
# All four = (0.4+0.6+0.2+0.8)/4 = 0.5
self.assertAlmostEqual(all_, 0.5)
# Front = (0.4+0.6)/2 = 0.5
self.assertAlmostEqual(front, 0.5)
# Rear = (0.2+0.8)/2 = 0.5
self.assertAlmostEqual(rear, 0.5)
def test_takes_absolute_values(self):
pkt = FakePacket(
tire_combined_slip_FL=-0.5,
tire_combined_slip_FR=0.5,
tire_combined_slip_RL=-0.3,
tire_combined_slip_RR=0.3,
)
all_, front, rear = ft._combined_slip(pkt)
self.assertAlmostEqual(all_, 0.4)
self.assertAlmostEqual(front, 0.5)
self.assertAlmostEqual(rear, 0.3)
# --- Race-on debounce tests -------------------------------------------------
class TestIsRaceOn(unittest.TestCase):
"""is_race_on returns the RAW flag (with FH5 RPM-stuck workaround)
only — hysteresis lives in commit_in_race so the run-loop can sequence
transition handling correctly. Tests here exercise just the raw read.
"""
def test_simple_yes(self):
s = ft.DaemonState()
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=200)
self.assertTrue(ft.is_race_on(pkt, s))
self.assertEqual(s.rpm_accumulator, 0)
def test_explicit_no(self):
s = ft.DaemonState()
pkt = FakePacket(is_race_on=0.0)
self.assertFalse(ft.is_race_on(pkt, s))
def test_rpm_stuck_overrides_flag_after_threshold(self):
# FH5's stuck-flag bug: is_race_on=1 but RPM and power show the car
# is actually in a menu (power<=0, RPM unchanged). is_race_on must
# return False once the accumulator trips, regardless of the flag.
s = ft.DaemonState()
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=800, power=0)
s.last_rpm = 800
for _ in range(ft.RPM_ACCUMULATOR_TRIGGER_RACE_OFF):
self.assertTrue(ft.is_race_on(pkt, s))
self.assertFalse(ft.is_race_on(pkt, s))
def test_rpm_change_resets_accumulator(self):
s = ft.DaemonState()
s.rpm_accumulator = 50
s.last_rpm = 800
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=100)
ft.is_race_on(pkt, s)
self.assertEqual(s.rpm_accumulator, 0)
class TestCommitInRace(unittest.TestCase):
"""Hysteresis on the raw is_race_on flag. FH5 emits packets where
the bit alternates True/False at packet rate during menu transitions
and loading screens. commit_in_race must filter those alternations.
"""
def test_stable_no_pending_count_never_grows(self):
s = ft.DaemonState()
s.last_in_race = False
for _ in range(100):
self.assertFalse(ft.commit_in_race(False, s))
self.assertEqual(s.in_race_pending_count, 0)
def test_alternation_does_not_commit(self):
# Worst case from real FH5: flag flips every other packet. We
# must keep returning the committed (initial) value forever.
s = ft.DaemonState()
s.last_in_race = False
for i in range(200):
raw = bool(i % 2)
self.assertFalse(ft.commit_in_race(raw, s))
# Pending count never accumulates past 1 because each True is
# immediately followed by a False that resets it.
self.assertLessEqual(s.in_race_pending_count, 1)
def test_n_consecutive_packets_commit_flip(self):
s = ft.DaemonState()
s.last_in_race = False
# First N-1 same-value packets do NOT commit yet.
for _ in range(ft.IN_RACE_HYSTERESIS_PACKETS - 1):
self.assertFalse(ft.commit_in_race(True, s))
# The Nth commits.
self.assertTrue(ft.commit_in_race(True, s))
# Pending count was reset to 0 on commit.
self.assertEqual(s.in_race_pending_count, 0)
def test_committed_value_returned_when_raw_matches(self):
s = ft.DaemonState()
s.last_in_race = True
# Even though state.last_in_race is True, raw flag matches so
# we just return last_in_race without touching pending count.
s.in_race_pending_count = 5
result = ft.commit_in_race(True, s)
self.assertTrue(result)
self.assertEqual(s.in_race_pending_count, 0)
# --- Trigger handler tests --------------------------------------------------
class TestHandleThrottle(unittest.TestCase):
def test_zero_pedal_baseline_min_feedback(self):
# Foot off throttle should NOT turn the trigger off — variant D's
# design is "always feels something". Baseline mode still fires
# with strength = MIN_THROTTLE_RESISTANCE so the user feels light
# constant resistance under the finger. The run-loop is the only
# place that calls effect.off() (out-of-race idle reset).
c = FakeController()
s = ft.DaemonState()
ft.handle_throttle(c, FakePacket(accel=0.0), s)
self.assertEqual(c.calls, [("R2", "feedback", 0, ft.MIN_THROTTLE_RESISTANCE)])
def test_baseline_under_normal_acceleration(self):
c = FakeController()
s = ft.DaemonState()
# No slip, moderate accel, throttle pressed.
pkt = FakePacket(accel=128, acceleration_x=0.5, acceleration_z=2.0)
ft.handle_throttle(c, pkt, s)
# Baseline mode → feedback call (not vibration, not off).
self.assertEqual(len(c.calls), 1)
self.assertEqual(c.calls[0][1], "feedback")
side, mode, start, strength = c.calls[0]
self.assertEqual(side, "R2")
self.assertEqual(start, 0)
# Strength is in valid library range.
self.assertGreaterEqual(strength, ft.MIN_THROTTLE_RESISTANCE)
self.assertLessEqual(strength, ft.MAX_THROTTLE_RESISTANCE)
def test_front_slip_triggers_vibration(self):
c = FakeController()
s = ft.DaemonState()
# Front-slip > 0.5 with throttle pressed → vibration mode.
# Use heavy accel so amp_raw is large enough that the EWMA-filtered
# value crosses the suppression threshold and we see vibration not
# feedback fallback.
pkt = FakePacket(
accel=255,
acceleration_z=10.0,
tire_combined_slip_FL=0.8,
tire_combined_slip_FR=0.8,
)
# Pre-warm EWMA so first call doesn't get heavily damped.
s.last_throttle_freq = ft.MAX_ACCEL_GRIPLOSS_VIBRATION
s.last_throttle_resistance = ft.MAX_ACCEL_GRIPLOSS_AMP
ft.handle_throttle(c, pkt, s)
self.assertEqual(len(c.calls), 1)
self.assertEqual(c.calls[0][1], "vibration")
def test_rear_slip_below_accelerator_gate_no_vibration(self):
# Rear-only slip but accelerator below the gate should NOT trigger
# vibration mode (Cosmii's rule, legacy_Program.cs:224).
c = FakeController()
s = ft.DaemonState()
pkt = FakePacket(
accel=100, # below ACCELERATOR_REAR_SLIP_GATE=200
tire_combined_slip_RL=0.8,
tire_combined_slip_RR=0.8,
)
ft.handle_throttle(c, pkt, s)
self.assertEqual(c.calls[0][1], "feedback") # baseline mode
def test_rear_slip_above_accelerator_gate_triggers_vibration(self):
c = FakeController()
s = ft.DaemonState()
s.last_throttle_freq = ft.MAX_ACCEL_GRIPLOSS_VIBRATION
s.last_throttle_resistance = ft.MAX_ACCEL_GRIPLOSS_AMP
pkt = FakePacket(
accel=255, # above ACCELERATOR_REAR_SLIP_GATE=200
acceleration_z=10.0,
tire_combined_slip_RL=0.8,
tire_combined_slip_RR=0.8,
)
ft.handle_throttle(c, pkt, s)
self.assertEqual(c.calls[0][1], "vibration")
class TestHandleBrake(unittest.TestCase):
def test_zero_pedal_baseline_min_feedback(self):
# Same as throttle — foot off brake = light constant resistance, not off.
c = FakeController()
s = ft.DaemonState()
ft.handle_brake(c, FakePacket(brake=0.0), s)
self.assertEqual(c.calls, [("L2", "feedback", 0, ft.MIN_BRAKE_RESISTANCE)])
def test_baseline_under_normal_braking(self):
c = FakeController()
s = ft.DaemonState()
pkt = FakePacket(brake=128)
ft.handle_brake(c, pkt, s)
self.assertEqual(len(c.calls), 1)
self.assertEqual(c.calls[0][1], "feedback")
# Strength scales with brake input. brake=128 → midpoint of 1..6 = 4ish.
_, _, _, strength = c.calls[0]
self.assertGreaterEqual(strength, ft.MIN_BRAKE_RESISTANCE)
self.assertLessEqual(strength, ft.MAX_BRAKE_RESISTANCE)
def test_slip_with_heavy_brake_triggers_vibration(self):
# Patmagauran/our condition: slip > thr AND brake > thr.
c = FakeController()
s = ft.DaemonState()
s.last_brake_freq = ft.MAX_BRAKE_VIBRATION
s.last_brake_resistance = ft.MAX_BRAKE_AMP
pkt = FakePacket(
brake=200, # well above BRAKE_VIBRATION_MODE_START=10
tire_combined_slip_FL=0.8,
tire_combined_slip_FR=0.8,
tire_combined_slip_RL=0.8,
tire_combined_slip_RR=0.8,
)
ft.handle_brake(c, pkt, s)
self.assertEqual(c.calls[0][1], "vibration")
def test_slip_with_light_brake_no_vibration(self):
# Slip but brake below threshold → baseline mode (Patmagauran's logic,
# NOT Cosmii's inverted version).
c = FakeController()
s = ft.DaemonState()
pkt = FakePacket(
brake=8, # below BRAKE_VIBRATION_MODE_START=10
tire_combined_slip_FL=0.8,
tire_combined_slip_FR=0.8,
)
ft.handle_brake(c, pkt, s)
self.assertEqual(c.calls[0][1], "feedback") # baseline mode
# --- Lightbar tests ---------------------------------------------------------
class TestLightbarColor(unittest.TestCase):
def test_in_race_low_rpm_green_floor(self):
# At idle RPM the green channel should hit GREEN_FLOOR (50), not 0.
s = ft.DaemonState()
pkt = FakePacket(
is_race_on=1.0,
current_engine_rpm=800, # = idle
engine_idle_rpm=800,
engine_max_rpm=8000,
)
r, g, b = ft.lightbar_color(pkt, s, in_race=True)
self.assertEqual(r, 0)
self.assertEqual(g, ft.GREEN_FLOOR)
self.assertEqual(b, 0)
def test_in_race_below_redline_green(self):
s = ft.DaemonState()
# 50% RPM ratio, below redline.
pkt = FakePacket(
is_race_on=1.0,
current_engine_rpm=4400,
engine_idle_rpm=800,
engine_max_rpm=8000,
)
r, g, b = ft.lightbar_color(pkt, s, in_race=True)
# Both red and green should rise; green not inverted yet.
self.assertGreater(g, 0)
self.assertLess(g, 255)
def test_in_race_above_redline_inverts_green(self):
s = ft.DaemonState()
# 95% RPM ratio, well past 85% redline.
pkt = FakePacket(
is_race_on=1.0,
current_engine_rpm=8000,
engine_idle_rpm=800,
engine_max_rpm=8000,
)
r, g, b = ft.lightbar_color(pkt, s, in_race=True)
# At 100% ratio, green=255 then inverted to 0; red=255.
self.assertEqual(r, 255)
self.assertEqual(g, 0)
def test_menu_class_d_color(self):
s = ft.DaemonState()
pkt = FakePacket(car_class=0, car_performance_index=255)
rgb = ft.lightbar_color(pkt, s, in_race=False)
# Class D at full CPI = exact palette color.
self.assertEqual(rgb, ft.CAR_CLASS_COLORS[0])
def test_menu_x_class_constant(self):
s = ft.DaemonState()
pkt = FakePacket(car_class=7, car_performance_index=999)
rgb = ft.lightbar_color(pkt, s, in_race=False)
self.assertEqual(rgb, ft.COLOR_CLASS_X)
def test_menu_class_d_packet_unsticks_previous_class(self):
# Switching from S2 → D MUST update the lightbar to D's palette,
# not keep showing S2 just because car_class=0 was previously
# treated as "no info" by Cosmii's broken `> 0` guard.
s = ft.DaemonState()
s.last_car_class = 5 # S2
s.last_cpi = 200
d_pkt = FakePacket(car_class=0, car_performance_index=180)
rgb = ft.lightbar_color(d_pkt, s, in_race=False)
# Expect tinted D-class color, NOT S2.
ratio = 180 / 255
expected = (
int(ratio * ft.CAR_CLASS_COLORS[0][0]),
int(ratio * ft.CAR_CLASS_COLORS[0][1]),
int(ratio * ft.CAR_CLASS_COLORS[0][2]),
)
self.assertEqual(rgb, expected)
# --- DaemonState lifecycle --------------------------------------------------
class TestDaemonStateReset(unittest.TestCase):
def test_reset_clears_filters_and_accumulator(self):
s = ft.DaemonState()
s.last_throttle_resistance = 99.0
s.last_brake_resistance = 99.0
s.last_throttle_freq = 99.0
s.last_brake_freq = 99.0
s.rpm_accumulator = 50
s.last_rpm = 4000
s.reset()
self.assertEqual(s.last_throttle_resistance, 1.0)
self.assertEqual(s.last_brake_resistance, 1.0)
self.assertEqual(s.last_throttle_freq, 0.0)
self.assertEqual(s.last_brake_freq, 0.0)
self.assertEqual(s.rpm_accumulator, 0)
self.assertEqual(s.last_rpm, 0.0)
def test_reset_clears_lightbar_dedup(self):
# last_color MUST be cleared on idle reset. The `apply_lightbar`
# dedup short-circuits when color == state.last_color; if reset
# left the cache holding the in-race color, resuming with the
# same color would skip the controller write — leaving the bar
# black after the (0,0,0) write reset_all sent at idle.
s = ft.DaemonState()
s.last_color = (1, 2, 3)
s.reset()
self.assertEqual(s.last_color, (0, 0, 0))
# --- Intensity attenuation regression ---------------------------------------
# Catches the bug where the EWMA cell stored the intensity-scaled value, so
# any FORZA_*_INTENSITY < 1 geometrically attenuated the output (steady-state
# at intensity=0.5, α=0.01 was ~50× too low). The fix decouples cell storage
# from output scaling. This test would have caught it in CI.
class TestIntensityScaling(unittest.TestCase):
def _run_steady(self, intensity_attr: str, side: str, pkt_kwargs: dict, n_iter: int = 300) -> int:
"""Drive a handler n_iter times with constant input and the named
intensity attribute monkey-patched, then return the final integer
strength sent on `side` ("L2" or "R2").
"""
c = FakeController()
s = ft.DaemonState()
for _ in range(n_iter):
if side == "R2":
ft.handle_throttle(c, FakePacket(**pkt_kwargs), s)
else:
ft.handle_brake(c, FakePacket(**pkt_kwargs), s)
# Last call's strength.
last = c.calls[-1]
# ("R2"/"L2", "feedback", start, strength) OR ("R2"/"L2", "vibration", start, amp, freq)
return last[3]
def test_throttle_baseline_intensity_proportional(self):
# With intensity=0.5, the output at α=0.01 (very smooth) must
# converge to ~0.5× the intensity=1.0 output, NOT to ~1% of it.
# We can't monkey-patch module-level constants from a method
# cleanly, so we re-import after env tweaks isn't an option in
# one process. Instead, pin RIGHT_TRIGGER_INTENSITY directly.
original = ft.RIGHT_TRIGGER_INTENSITY
try:
ft.RIGHT_TRIGGER_INTENSITY = 1.0
full = self._run_steady("RIGHT_TRIGGER_INTENSITY", "R2",
dict(accel=128, acceleration_z=10.0))
ft.RIGHT_TRIGGER_INTENSITY = 0.5
half = self._run_steady("RIGHT_TRIGGER_INTENSITY", "R2",
dict(accel=128, acceleration_z=10.0))
finally:
ft.RIGHT_TRIGGER_INTENSITY = original
# full strength at this input is MAX=6 (avgAccel=10 → top of range).
# half intensity should clamp around 3 (or MIN=1 floor; not <full).
self.assertEqual(full, 6)
self.assertEqual(half, 3)
def test_brake_baseline_intensity_proportional(self):
original = ft.LEFT_TRIGGER_INTENSITY
try:
ft.LEFT_TRIGGER_INTENSITY = 1.0
full = self._run_steady("LEFT_TRIGGER_INTENSITY", "L2", dict(brake=255))
ft.LEFT_TRIGGER_INTENSITY = 0.5
half = self._run_steady("LEFT_TRIGGER_INTENSITY", "L2", dict(brake=255))
finally:
ft.LEFT_TRIGGER_INTENSITY = original
self.assertEqual(full, 6)
self.assertEqual(half, 3)
# --- Slip transition seeding ------------------------------------------------
# The first packet of a slip event MUST produce the unsmoothed peak
# vibration so the buzz is felt immediately, not after ~1.7s of EWMA
# convergence at α=0.01.
class TestSlipTransitionSeeding(unittest.TestCase):
def test_first_throttle_slip_packet_hits_peak(self):
c = FakeController()
s = ft.DaemonState() # was_throttle_slipping=False, cells=1.0/0.0
pkt = FakePacket(
accel=255,
acceleration_z=10.0,
tire_combined_slip_FL=1.0,
tire_combined_slip_FR=1.0,
tire_combined_slip_RL=1.0,
tire_combined_slip_RR=1.0,
)
ft.handle_throttle(c, pkt, s)
self.assertEqual(c.calls[-1][1], "vibration")
_, _, _, amp, freq = c.calls[-1]
# Peak slip → MAX_ACCEL_GRIPLOSS_VIBRATION ceiling on freq, MAX
# amp from heavy avgAccel. With seeding, BOTH should hit max on
# the very first packet.
self.assertEqual(freq, ft.MAX_ACCEL_GRIPLOSS_VIBRATION)
self.assertEqual(amp, ft.MAX_ACCEL_GRIPLOSS_AMP)
def test_first_brake_slip_packet_hits_peak(self):
c = FakeController()
s = ft.DaemonState()
pkt = FakePacket(
brake=255,
tire_combined_slip_FL=1.0,
tire_combined_slip_FR=1.0,
tire_combined_slip_RL=1.0,
tire_combined_slip_RR=1.0,
)
ft.handle_brake(c, pkt, s)
self.assertEqual(c.calls[-1][1], "vibration")
_, _, _, amp, freq = c.calls[-1]
self.assertEqual(freq, ft.MAX_BRAKE_VIBRATION)
self.assertEqual(amp, ft.MAX_BRAKE_AMP)
def test_throttle_seeding_survives_menu_round_trip(self):
# Race 1 ended with was_throttle_slipping=True (player crashed
# mid-spin). Player went to menu (in_race=False) — the run-loop's
# transition handler MUST clear was_throttle_slipping so race 2's
# first slip packet re-seeds the EWMA cell. Without the transition
# cleanup, the round-1 cold-start fix doesn't survive a menu
# round-trip and the regression returns silently.
c = FakeController()
s = ft.DaemonState()
# Simulate post-menu state with stale slip flags + EWMA cells.
s.was_throttle_slipping = False # what run-loop transition handler should leave
s.last_throttle_freq = 1.0 # but cells are not stale because run-loop reset them
s.last_throttle_resistance = 1.0
pkt = FakePacket(
accel=255, acceleration_z=10.0,
tire_combined_slip_FL=1.0, tire_combined_slip_FR=1.0,
tire_combined_slip_RL=1.0, tire_combined_slip_RR=1.0,
)
ft.handle_throttle(c, pkt, s)
_, _, _, amp, freq = c.calls[-1]
self.assertEqual(freq, ft.MAX_ACCEL_GRIPLOSS_VIBRATION)
self.assertEqual(amp, ft.MAX_ACCEL_GRIPLOSS_AMP)
def test_brake_seeding_survives_menu_round_trip(self):
c = FakeController()
s = ft.DaemonState()
s.was_brake_slipping = False
s.last_brake_freq = 1.0
s.last_brake_resistance = 1.0
pkt = FakePacket(
brake=255,
tire_combined_slip_FL=1.0, tire_combined_slip_FR=1.0,
tire_combined_slip_RL=1.0, tire_combined_slip_RR=1.0,
)
ft.handle_brake(c, pkt, s)
_, _, _, amp, freq = c.calls[-1]
self.assertEqual(freq, ft.MAX_BRAKE_VIBRATION)
self.assertEqual(amp, ft.MAX_BRAKE_AMP)
# --- env helpers ------------------------------------------------------------
class TestReadIntensity(unittest.TestCase):
def test_default_when_unset(self):
os.environ.pop("FORZA_PROBE_X", None)
self.assertEqual(ft._read_intensity("FORZA_PROBE_X", default=0.7), 0.7)
def test_clamps_above_one(self):
os.environ["FORZA_PROBE_X"] = "5.0"
self.assertEqual(ft._read_intensity("FORZA_PROBE_X"), 1.0)
def test_clamps_below_zero(self):
os.environ["FORZA_PROBE_X"] = "-1.0"
self.assertEqual(ft._read_intensity("FORZA_PROBE_X"), 0.0)
def test_nan_falls_back_to_default(self):
# Without the is_finite check, min(1.0, nan) returns 1.0 silently.
os.environ["FORZA_PROBE_X"] = "nan"
self.assertEqual(ft._read_intensity("FORZA_PROBE_X", default=0.4), 0.4)
def test_inf_falls_back_to_default(self):
os.environ["FORZA_PROBE_X"] = "inf"
self.assertEqual(ft._read_intensity("FORZA_PROBE_X", default=0.4), 0.4)
def test_garbage_falls_back_to_default(self):
os.environ["FORZA_PROBE_X"] = "yes please"
self.assertEqual(ft._read_intensity("FORZA_PROBE_X", default=0.5), 0.5)
class TestReadBool(unittest.TestCase):
def test_default(self):
os.environ.pop("FORZA_PROBE_BOOL", None)
self.assertTrue(ft._read_bool("FORZA_PROBE_BOOL", default=True))
self.assertFalse(ft._read_bool("FORZA_PROBE_BOOL", default=False))
def test_disabled_tokens(self):
for tok in ("0", "false", "FALSE", "no", "NO", "off", "Off", ""):
os.environ["FORZA_PROBE_BOOL"] = tok
self.assertFalse(ft._read_bool("FORZA_PROBE_BOOL"), f"token={tok!r}")
def test_enabled_tokens(self):
for tok in ("1", "true", "yes", "on", "Y", "anything-else"):
os.environ["FORZA_PROBE_BOOL"] = tok
self.assertTrue(ft._read_bool("FORZA_PROBE_BOOL"), f"token={tok!r}")
# --- on_error signature -----------------------------------------------------
# The dualsense-controller library dispatches state-change callbacks by
# parameter count. _on_controller_error MUST take exactly 1 parameter to
# bind to the 1-arg event (which emits new_value). 2-arg would bind to a
# 2-arg event that emits (new_value, timestamp_ns), making the log show a
# stray clock integer instead of the exception.
class TestOnControllerErrorSignature(unittest.TestCase):
def test_takes_exactly_one_argument(self):
import inspect
sig = inspect.signature(ft._on_controller_error)
self.assertEqual(
len(sig.parameters), 1,
f"_on_controller_error must take 1 arg (got {list(sig.parameters)}); "
"the dualsense-controller library dispatches by arity and 2-arg "
"would receive (new_value, timestamp_ns) instead of (exc).",
)
# --- Reset tolerates None controller ----------------------------------------
class TestResetNullSafe(unittest.TestCase):
def test_reset_triggers_none_no_raise(self):
ft.reset_triggers(None) # must not raise
def test_reset_all_none_no_raise(self):
ft.reset_all(None) # must not raise
# --- fdp surface contract ---------------------------------------------------
# Catches the field-name regression class. We collect every literal name
# the daemon passes to _safe_field/_safe_abs and assert each resolves on
# a real fdp.ForzaDataPacket. This would have caught all three of the
# bugs in commit 50e5f12 at CI time.
class TestFdpSurfaceContract(unittest.TestCase):
@staticmethod
def _collect_field_names(src_path: str) -> tuple[set[str], int]:
"""Walk the daemon AST for every _safe_field/_safe_abs call.
Returns (literal_names, non_literal_arg_count). Calls inside the
wrapper functions `_safe_abs` and `_safe_field` themselves are
excluded — those forward `name` from a parameter to the inner
call and do NOT touch the fdp surface directly. Non-zero
non-literal count from any OTHER caller indicates the contract
isn't fully enforceable.
"""
import ast
with open(src_path) as f:
tree = ast.parse(f.read())
class V(ast.NodeVisitor):
def __init__(self) -> None:
self.names: set[str] = set()
self.non_literal: int = 0
self._fn_stack: list[str] = []
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._fn_stack.append(node.name)
self.generic_visit(node)
self._fn_stack.pop()
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._fn_stack.append(node.name)
self.generic_visit(node)
self._fn_stack.pop()
def visit_Call(self, node: ast.Call) -> None:
self.generic_visit(node) # nested calls
fn = node.func
if not isinstance(fn, ast.Name):
return
if fn.id not in ("_safe_field", "_safe_abs"):
return
# Skip the forwarding wrappers themselves: those call
# _safe_field with a `name` parameter, not a literal.
if self._fn_stack and self._fn_stack[-1] in ("_safe_abs", "_safe_field"):
return
if len(node.args) < 2:
self.non_literal += 1
return
arg = node.args[1]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
self.names.add(arg.value)
else:
self.non_literal += 1
v = V()
v.visit(tree)
return v.names, v.non_literal
def test_every_daemon_field_resolves_on_real_fdp_packet(self):
import fdp
# 324-byte zeroed buffer — fh4 unpack will succeed (just yields
# zeros for everything) but every documented field will be
# setattr'd on the resulting object.
pkt = fdp.ForzaDataPacket(b"\x00" * 324, packet_format="fh4")
used_names, non_literal = self._collect_field_names(ft.__file__)
self.assertGreater(len(used_names), 5, "AST walker found too few names")
self.assertEqual(
non_literal, 0,
"Every _safe_field/_safe_abs call MUST pass a string literal "
"field name so this contract test can verify it. Variable / "
"kwarg / computed names defeat the AST walker.",
)
missing = sorted(n for n in used_names if not hasattr(pkt, n))
self.assertEqual(
missing, [],
f"daemon reads fdp fields that don't exist: {missing}. "
"Check fdp.ForzaDataPacket.sled_props + dash_props for canonical names.",
)
if __name__ == "__main__":
unittest.main(verbosity=2)