Files
track-core/python/tests/test_bindings.py
T
crosstyan 7b0f6a523d feat(emulator): add editable training schema panel
Redesign the DearPyGui emulator into a two-column layout with an interactive training schema editor and runtime preview.

Add JSON load/save support for ST, SM, MT, and RSMT schemes, including draft normalization, conversion to core schemes, and explicit Apply validation so intermediate edits do not restart or block the preview.

Update manual mode to clamp negative mileage, remove speed from manual positioning, preserve stopped runtime state when reloading schemas, and keep linear rendering visible with span-based drawing.

Fix the core linear pingpong render plan so reverse travel remains visible near endpoints and prefix/postfix colors stay tied to physical sides instead of reversing with heading.

Add C++ and Python regressions for schema conversion, emulator edit behavior, manual mode, linear endpoint visibility, and fixed prefix/postfix colors.
2026-05-18 17:23:00 +08:00

370 lines
10 KiB
Python

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