diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index ff841f4..e81e716 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2295,7 +2295,7 @@ def _parse_time_string( TIME, { "timeBase": FpyValue(TIME_BASE, time_base), - "timeContext": FpyValue(U8, time_context), + "timeContext": FpyValue(TIME.members[1].type, time_context), "seconds": FpyValue(U32, seconds), "useconds": FpyValue(U32, useconds), }, diff --git a/src/fpy/state.py b/src/fpy/state.py index 4e5703f..66b8ab5 100644 --- a/src/fpy/state.py +++ b/src/fpy/state.py @@ -423,7 +423,7 @@ def _update_seq_args_from_dict(dict_type_name_dict: dict[str, FpyType]) -> None: def _update_time_context_type_from_dict( dict_type_name_dict: dict[str, FpyType], ) -> None: - """Update TIME's timeContext member type from FwTimeContextStoreType.""" + """Configure the time member and builtin argument from the dictionary.""" if "FwTimeContextStoreType" not in dict_type_name_dict: return # Keep the default U8 ctx_type = dict_type_name_dict["FwTimeContextStoreType"] @@ -434,6 +434,7 @@ def _update_time_context_type_from_dict( f"it as {ctx_type}.", ) TIME.members[1].type = ctx_type + TIME_MACRO.args[2] = ("timeContext", ctx_type, FpyValue(ctx_type, 0)) def _get_elem_type_default(elem_type: FpyType) -> FpyValue: diff --git a/test/fpy/test_time_context_config.py b/test/fpy/test_time_context_config.py new file mode 100644 index 0000000..694afaa --- /dev/null +++ b/test/fpy/test_time_context_config.py @@ -0,0 +1,163 @@ +"""Regression tests for dictionary-configured time contexts.""" + +import json +from pathlib import Path + +import pytest + +from fpy.compiler import ( + analysis_to_fpybc_directives, + analysis_to_wasm, + analyze_ast, + text_to_ast, +) +from fpy.dictionary import load_dictionary +from fpy.error import CompileError +from fpy.macros import TIME_MACRO +from fpy.state import _build_global_scopes, get_base_compile_state +from fpy.test_helpers import FPYBC, assert_run_success, default_dictionary +from fpy.types import TIME + + +@pytest.fixture(autouse=True) +def clear_dictionary_caches(): + """Each test loads one configuration without reusing another test's scopes.""" + load_dictionary.cache_clear() + _build_global_scopes.cache_clear() + # TIME also keeps derived defaults outside the cached scopes. + TIME.member_defaults = None + yield + load_dictionary.cache_clear() + _build_global_scopes.cache_clear() + TIME.member_defaults = None + get_base_compile_state(default_dictionary) + + +@pytest.fixture( + params=[ + {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + {"name": "U16", "kind": "integer", "size": 16, "signed": False}, + {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + ], + ids=["U8", "U16", "U32", "U64"], +) +def context_dictionary(request, tmp_path): + """Use the reference dictionary with one explicit time-context type.""" + data = json.loads(Path(default_dictionary).read_text(encoding="utf-8")) + data["typeDefinitions"] = [ + definition + for definition in data["typeDefinitions"] + if definition["qualifiedName"] != "FwTimeContextStoreType" + ] + [ + { + "kind": "alias", + "qualifiedName": "FwTimeContextStoreType", + "type": request.param, + "underlyingType": request.param, + } + ] + path = tmp_path / "dictionary.json" + path.write_text(json.dumps(data), encoding="utf-8") + return str(path) + + +def _time_value(dictionary, context): + suffix = "" if context is None else f", timeContext={context}" + body = text_to_ast(f'time("2023-11-14T22:13:20.5Z"{suffix})\n') + call = body.stmts[0] + state = analyze_ast(body, get_base_compile_state(dictionary)) + return state.const_expr_values[call] + + +def test_time_builtin_context_parameter_type(context_dictionary): + get_base_compile_state(context_dictionary) + configured = load_dictionary(context_dictionary)["type_defs"][ + "FwTimeContextStoreType" + ] + assert TIME.members[1].type == configured + assert TIME.member_defaults["timeContext"].type == configured + assert TIME_MACRO.args[2][1] == configured + assert TIME_MACRO.args[2][2].type == configured + + +@pytest.mark.parametrize("context", [None, 9], ids=["default-context", "context-nine"]) +def test_time_constant_context_layout(context_dictionary, context): + value = _time_value(context_dictionary, context) + types = load_dictionary(context_dictionary)["type_defs"] + configured = types["FwTimeContextStoreType"] + expected = ( + types["TimeBase"] + .enum_dict["TB_WORKSTATION_TIME"] + .to_bytes(types["TimeBase"].max_size, "big") + + (0 if context is None else context).to_bytes(configured.max_size, "big") + + (1_700_000_000).to_bytes(4, "big") + + (500_000).to_bytes(4, "big") + ) + serialized = value.serialize() + assert len(serialized) == len(expected) + assert serialized == expected + assert value.val["timeContext"].type == configured + assert len(serialized) == value.type.max_size + + +def test_time_context_300_matches_configured_range(context_dictionary): + configured = load_dictionary(context_dictionary)["type_defs"][ + "FwTimeContextStoreType" + ] + if configured.bits == 8: + with pytest.raises(CompileError, match="out of range for type U8"): + _time_value(context_dictionary, 300) + else: + value = _time_value(context_dictionary, 300) + assert value.val["timeContext"].val == 300 + assert value.val["timeContext"].type == configured + + +def test_time_assignment_compiles_for_configured_context( + context_dictionary, single_backend +): + body = text_to_ast( + 't: Fw.Time = time("2023-11-14T22:13:20.5Z", timeContext=9)\n' + "assert t.timeContext == 9\n" + ) + state = analyze_ast(body, get_base_compile_state(context_dictionary)) + if single_backend == FPYBC: + analysis_to_fpybc_directives(state) + else: + analysis_to_wasm(state) + + +def test_time_context_range_boundaries(context_dictionary): + configured = load_dictionary(context_dictionary)["type_defs"][ + "FwTimeContextStoreType" + ] + maximum = (1 << configured.bits) - 1 + assert _time_value(context_dictionary, maximum).val["timeContext"].val == maximum + for invalid in (-1, maximum + 1): + with pytest.raises( + CompileError, match=f"out of range for type {configured.name}" + ): + _time_value(context_dictionary, invalid) + + +@pytest.mark.parametrize( + "context", [None, 9, "maximum"], ids=["default", "nine", "maximum"] +) +def test_time_context_runs_on_sequencer(fprime_test_api, single_backend, context): + configured = load_dictionary(default_dictionary)["type_defs"][ + "FwTimeContextStoreType" + ] + if context == "maximum": + context = (1 << configured.bits) - 1 + suffix = "" if context is None else f", timeContext={context}" + expected = 0 if context is None else context + seq = ( + f't: Fw.Time = time("2023-11-14T22:13:20.5Z"{suffix})\n' + f"assert t.timeContext == {expected}\n" + "assert t.timeBase == TimeBase.TB_WORKSTATION_TIME\n" + "assert t.seconds == 1700000000\n" + "assert t.useconds == 500000\n" + "assert (t + Fw.TimeIntervalValue(1, 0)).timeContext == t.timeContext\n" + ) + assert_run_success(fprime_test_api, seq)