import pytest import track_core as tc from track_core import emulator from track_core import emulator_scheme def running_info(color=None): info = tc.TrackInfo() info.color = color or tc.Color.green() info.id = 1 info.is_running = True info.num_segments = 1 return info def report(mileage): value = tc.TrackReport() value.id = 1 value.state = tc.TrackState.run value.mileage_m = mileage value.speed_m_s = 1.0 return value def config(draw_kind): value = tc.TrackConfig() value.draw_kind = draw_kind value.line_length_m = 10.0 value.active_line_length_m = 4.0 value.head_offset_m = 0.0 value.line_leds_num = 20 return value class FakeDpg: def __init__(self, values): self.values = values def get_value(self, tag): return self.values[tag] def set_value(self, tag, value): self.values[tag] = value def does_item_exist(self, tag): return False def configure_item(self, tag, **kwargs): pass def emulator_values(**overrides): values = { "line_length": 40.0, "active_length": 10.0, "head_offset": 0.0, "led_count": 160, "draw_kind": "linear", "runner_color": (0, 255, 0, 255), "manual_running": True, "manual_mileage": 8.5, "schema_id": 1, "schema_kind": "ST", "schema_accel": "smooth", "schema_color": (0, 255, 0, 255), } values.update(overrides) return values def test_circular_render_wraps(): plan = tc.make_render_plan( config(tc.TrackDrawKind.circular), running_info(tc.Color.green()), report(8.5), ) assert plan.span_count == 2 assert [(span.start_led, span.led_count, span.color) for span in plan.spans] == [ (17, 3, tc.Color.green()), (0, 5, tc.Color.green()), ] def test_linear_render_forward_pixels(): pixels = tc.render_pixels( config(tc.TrackDrawKind.linear), running_info(tc.Color.red()), report(5.0), ) assert len(pixels) == 20 assert pixels[6] == tc.Color.cyan() assert pixels[8] == tc.Color.red() assert pixels[10] == tc.Color.red() assert pixels[12] == tc.Color.blue() assert pixels[0] == tc.Color.black() def test_linear_render_reverse_spans(): cfg = config(tc.TrackDrawKind.linear) cfg.active_line_length_m = 5.0 plan = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(15.0)) assert [(span.start_led, span.led_count, span.color) for span in plan.spans] == [ (10, 3, tc.Color.red()), (13, 2, tc.Color.blue()), (5, 2, tc.Color.cyan()), (7, 3, tc.Color.red()), ] def test_linear_render_prefix_postfix_colors_do_not_reverse(): cfg = config(tc.TrackDrawKind.linear) cfg.active_line_length_m = 5.0 forward = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(5.0)) reverse = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(15.0)) assert [(span.start_led, span.led_count, span.color) for span in reverse.spans] == [ (span.start_led, span.led_count, span.color) for span in forward.spans ] def test_linear_render_boundary_sweep_does_not_raise(): cfg = config(tc.TrackDrawKind.linear) for mileage in ( -20.0, -10.1, -10.0, -9.9, -1.0, -0.1, 0.0, 0.1, 1.0, 9.9, 10.0, 10.1, 19.9, 20.0, 20.1, 29.9, 30.0, 30.1, 40.0, ): pixels = tc.render_pixels(cfg, running_info(tc.Color.red()), report(mileage)) assert len(pixels) == cfg.line_leds_num @pytest.mark.parametrize("mileage", [10.1, 19.9]) def test_linear_render_reverse_near_ends_stays_visible(mileage): cfg = config(tc.TrackDrawKind.linear) pixels = tc.render_pixels(cfg, running_info(tc.Color.red()), report(mileage)) assert any(pixel != tc.Color.black() for pixel in pixels) def test_manual_emulator_report_clamps_negative_mileage_and_ignores_speed(): dpg = FakeDpg(emulator_values(manual_mileage=-12.0)) _, info, state_report = emulator._manual_state_to_core(dpg) assert info.is_running assert state_report.mileage_m == 0.0 assert state_report.speed_m_s == 0.0 def test_emulator_track_model_toggle(): dpg = FakeDpg(emulator_values(draw_kind="circular")) emulator._toggle_draw_kind(dpg) assert dpg.get_value("draw_kind") == "linear" emulator._toggle_draw_kind(dpg) assert dpg.get_value("draw_kind") == "circular" def test_emulator_schema_reload_preserves_stopped_runtime(): app = emulator.TrackRuntimeApp() dpg = FakeDpg(emulator_values()) assert app.load_scheme(dpg) assert not app.runtime.all_stopped() app.stop() assert app.runtime.all_stopped() app.draft["segments"][1]["speed_m_s"] = 2.5 assert app.load_scheme(dpg) assert app.runtime.all_stopped() def test_emulator_editor_changes_wait_for_explicit_apply(): app = emulator.TrackRuntimeApp() dpg = FakeDpg(emulator_values()) assert app.load_scheme(dpg) previous_valid = emulator_scheme.clone_draft(app.last_valid_draft) emulator._set_simple_segment_value(dpg, app, 0, "time_s", 3) assert app.draft["segments"][0]["time_s"] == 3 assert app.last_valid_draft == previous_valid assert app.has_unapplied_edits assert "Edited" in app.status assert not app.apply_schema(dpg) assert app.last_valid_draft == previous_valid @pytest.mark.parametrize("kind", ["ST", "SM", "MT", "RSMT"]) def test_emulator_scheme_json_round_trips_all_kinds(tmp_path, kind): draft = emulator_scheme.default_draft(kind) path = tmp_path / f"{kind.lower()}.json" saved = emulator_scheme.save_draft(path, draft) loaded = emulator_scheme.load_draft(path) assert loaded == saved assert loaded["version"] == 1 assert loaded["kind"] == kind @pytest.mark.parametrize("kind", ["ST", "SM", "MT", "RSMT"]) def test_emulator_scheme_draft_converts_to_core_scheme(kind): scheme = emulator_scheme.draft_to_scheme(emulator_scheme.default_draft(kind)) runtime = tc.make_scheme_track_runtime(scheme) runtime = tc.start_scheme_track(runtime) assert runtime.info().id == 1 assert runtime.report().state == tc.TrackState.run @pytest.mark.parametrize( "kind,segments,error", [ ("ST", [{"time_s": 1, "speed_m_s": 1.0}], "first ST time_s"), ("SM", [{"mileage_m": 1, "speed_m_s": 1.0}], "first SM mileage_m"), ("MT", [{"time_s": 0, "mileage_m": 1}], "MT needs at least two segments"), ( "RSMT", [{"time_s": 0, "sub_segments": [{"mileage_m": 1, "speed_m_s": 1.0}]}], "first RSMT segment 0 mileage_m", ), ], ) def test_emulator_scheme_rejects_invalid_segments(kind, segments, error): draft = emulator_scheme.default_draft(kind) draft["segments"] = segments with pytest.raises(ValueError, match=error): emulator_scheme.normalize_draft(draft) def test_not_running_renders_black_pixels(): info = running_info() info.is_running = False pixels = tc.render_pixels(config(tc.TrackDrawKind.circular), info, report(8.5)) assert len(pixels) == 20 assert all(pixel == tc.Color.black() for pixel in pixels) def test_invalid_config_raises_value_error(): cfg = config(tc.TrackDrawKind.circular) cfg.line_leds_num = 0 with pytest.raises(ValueError): tc.render_pixels(cfg, running_info(), report(0.0)) def st_segment(speed, time_s): segment = tc.STSegment() segment.speed_m_s = speed segment.time_since_start_s = time_s return segment def sm_segment(speed, mileage): segment = tc.SMSegment() segment.speed_m_s = speed segment.mileage_from_start_m = mileage return segment def mt_segment(mileage, time_s): segment = tc.MTSegment() segment.mileage_to_travel_this_segment_m = mileage segment.time_since_start_s = time_s return segment def test_pure_speed_time_runtime_ticks(): scheme = tc.make_speed_time_scheme( 7, tc.Color.green(), tc.AccelerationProfile.instant, [st_segment(1.0, 0), st_segment(3.0, 5), st_segment(1.0, 10)], ) runtime = tc.start_scheme_track(tc.make_scheme_track_runtime(scheme)) runtime = tc.tick_scheme_track(config(tc.TrackDrawKind.circular), runtime, 6.0) state = runtime.state report = runtime.report() assert state.primary_segment_index == 1 assert report.state == tc.TrackState.run assert report.speed_m_s == pytest.approx(3.0) assert report.mileage_m == pytest.approx(6.0) def test_training_runtime_accepts_all_scheme_kinds_and_renders(): runtime = tc.SchemeTrainingRuntime() runtime.add_scheme( tc.make_speed_mileage_scheme( 1, tc.Color.red(), tc.AccelerationProfile.instant, [sm_segment(1.0, 0), sm_segment(2.0, 5), sm_segment(1.0, 10)], ) ) runtime.add_scheme( tc.make_mileage_time_scheme( 2, tc.Color.green(), tc.AccelerationProfile.instant, [mt_segment(10, 0), mt_segment(20, 5), mt_segment(1, 15)], ) ) runtime.add_scheme( tc.make_speed_time_scheme( 3, tc.Color.blue(), tc.AccelerationProfile.instant, [st_segment(1.0, 0), st_segment(1.0, 10)], ) ) repeated = tc.RepeatedSMSegment() repeated.time_since_start_s = 0 repeated.speed_mileage_segments = [sm_segment(1.0, 0), sm_segment(2.0, 5)] repeated_end = tc.RepeatedSMSegment() repeated_end.time_since_start_s = 20 repeated_end.speed_mileage_segments = [sm_segment(1.0, 0), sm_segment(1.0, 5)] runtime.add_scheme( tc.make_repeated_speed_mileage_time_scheme( 4, tc.Color.white(), tc.AccelerationProfile.instant, [repeated, repeated_end], ) ) runtime.start() runtime.tick(config(tc.TrackDrawKind.circular), 1.0) pixels = runtime.render_pixels(config(tc.TrackDrawKind.circular)) assert runtime.has_program() assert not runtime.all_stopped() assert len(runtime.state_collection()) == 4 assert len(runtime.scheme_status()) == 4 assert len(pixels) == 20