diff --git a/CMakeLists.txt b/CMakeLists.txt index ab5b5f4..2de345a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,5 +30,6 @@ add_subdirectory(test) install(DIRECTORY sample-config/ DESTINATION sample_configs) install(DIRECTORY doc/ DESTINATION doc) +install(DIRECTORY macros/ DESTINATION ${PLUGIN_INSTALL_DIR}/macros) install(FILES sample-config/board-config.ini DESTINATION ${PLUGIN_INSTALL_DIR}) install(FILES 3rdparty/FIP-SDK/fonts/fip-fonts.bmp DESTINATION ${PLUGIN_INSTALL_DIR}) diff --git a/doc/documentation.md b/doc/documentation.md index 6ecb4ed..92a568e 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -758,6 +758,72 @@ text="lua:fip_text_test()" ``` +## FIP macros +Defining a FIP page layer by layer (as described above) is flexible but verbose, and it has to be +repeated for every aircraft even when the instrument is the same - only the datarefs differ. Macros +let you describe an instrument (for example a Garmin G5) **once** as a reusable template with named +parameters, and then use it from any aircraft config by binding those parameters to the aircraft's +datarefs, lua functions or constants. + +### Where macros live +Macro files are placed in a `macros` subfolder next to the plugin, i.e. +`.../resources/plugins/xpanel/64/macros/`. Each macro is a single file named `.macro`. Any bmp +assets a macro references are resolved **relative to the `macros` folder** (e.g. +`macros/fip-images/...`), so a macro is fully self-contained and does not depend on files in the +aircraft folder. + +### The macro file format +A `.macro` file uses the same syntax as a normal FIP `[screen]`/`[page]`/`[layer]` definition, with +one addition: any value may be written as a parameter placeholder `"@{name}"`. The optional +`[macro_info]` section carries metadata; `device` must be `fip`. + +```ini +[macro_info] +name="garmin_g5" +description="Garmin G5 Electronic Flight Instrument created for General Aviation" +device="fip" +version="1.0" + +[page:id="HSI"] + [layer:image="fip-images/HSIb_card_hdg.bmp,ref_x:90,ref_y:90,base_rot:0"] + rotation="@{hsi_course}" + offset_x="const:200" + offset_y="const:120" + + [layer:type="text"] + offset_x="const:38" + offset_y="const:218" + text="@{current_baro_hpa}" +``` + +A macro can define several pages. Any value that is not a placeholder (`const:`, `dataref:`, `lua:`) +is used as-is, so a page can mix fixed and parameterized values, or be fully static. + +### Using a macro from an aircraft config +Add `@use=""` inside a `[screen:...]` section to pull in all of the macro's pages. For +each page that has placeholders, add a `[@page:id=""]` block and bind every placeholder with +an `@parameter` line of the form `name:,value:`. The `` is exactly what +you would otherwise write for that field (`dataref:...`, `lua:...` or `const:...`). + +```ini +[device:id="saitek_fip_screen"] +serial="MZB05779E2" + + [screen:id="fip-screen"] + @use="garmin_g5" + + [@page:id="HSI"] + @parameter="name:hsi_course,value:dataref:AirbusFBW/HSICourse,scale:-1.0" + @parameter="name:current_baro_hpa,value:lua:get_current_baro_hpa()" +``` + +Notes: +* Every `@{name}` used on a page must be bound by an `@parameter` in that page's `[@page]` block; + an unbound parameter is a configuration error. +* Pages that contain no placeholders need no `[@page]` block. +* You may `@use` more than one macro in the same screen; page ids must be unique across them. +* A macro shipped with the plugin, `macros/garmin_g5.macro`, is installed as a working example. + ## Generate new fonts for text layers## The plugin has been released with a simple font set (fip-fonts.bmp). If you'd like to generate a new font collection you can use [bmfont](http://www.angelcode.com/products/bmfont/) tool. diff --git a/macros/fip-images/HSIb_card_hdg.bmp b/macros/fip-images/HSIb_card_hdg.bmp new file mode 100644 index 0000000..4c6f90c Binary files /dev/null and b/macros/fip-images/HSIb_card_hdg.bmp differ diff --git a/macros/fip-images/HSIb_mask_front.bmp b/macros/fip-images/HSIb_mask_front.bmp new file mode 100644 index 0000000..ee81c9b Binary files /dev/null and b/macros/fip-images/HSIb_mask_front.bmp differ diff --git a/macros/fip-images/HSIb_pointer_crs_tf.bmp b/macros/fip-images/HSIb_pointer_crs_tf.bmp new file mode 100644 index 0000000..95c29a1 Binary files /dev/null and b/macros/fip-images/HSIb_pointer_crs_tf.bmp differ diff --git a/macros/garmin_g5.macro b/macros/garmin_g5.macro new file mode 100644 index 0000000..1445aed --- /dev/null +++ b/macros/garmin_g5.macro @@ -0,0 +1,53 @@ +; Copyright (C) 2026 Norbert Takacs +; SPDX-License-Identifier: GPL-3.0-or-later +; +; Example FIP macro: a Garmin G5 style HSI page. +; +; A macro describes a reusable instrument once. Values written as "@{name}" are +; parameters that the aircraft config binds to a dataref / lua / constant with +; @parameter lines. The bmp assets referenced below live next to this file, under +; /macros/fip-images/. +; +; To use it from an aircraft config: +; +; [device:id="saitek_fip_screen"] +; serial="YOUR_DEVICE_SERIAL" +; [screen:id="fip-screen"] +; @use="garmin_g5" +; +; [@page:id="HSI"] +; @parameter="name:hsi_course,value:dataref:AirbusFBW/HSICourse,scale:-1.0" +; @parameter="name:hsi_course_rotation,value:lua:hsi_course_rotation()" +; @parameter="name:current_baro_hpa,value:lua:get_current_baro_hpa()" + +[macro_info] +name="garmin_g5" +description="Garmin G5 Electronic Flight Instrument created for General Aviation" +device="fip" +version="1.0" + +[page:id="HSI"] + [layer:image="fip-images/HSIb_card_hdg.bmp,ref_x:90,ref_y:90,base_rot:0"] + rotation="@{hsi_course}" + offset_x="const:200" + offset_y="const:120" + + [layer:image="fip-images/HSIb_pointer_crs_tf.bmp,ref_x:70,ref_y:71,base_rot:0"] + rotation="@{hsi_course_rotation}" + offset_x="const:200" + offset_y="const:120" + + [layer:image="fip-images/HSIb_mask_front.bmp,ref_x:120,ref_y:120,base_rot:0"] + rotation="const:0" + offset_x="const:200" + offset_y="const:120" + + [layer:type="text"] + offset_x="const:3" + offset_y="const:218" + text="const:QNH" + + [layer:type="text"] + offset_x="const:38" + offset_y="const:218" + text="@{current_baro_hpa}" diff --git a/src/core/ConfigParser.cpp b/src/core/ConfigParser.cpp index 6cfd290..09554d4 100644 --- a/src/core/ConfigParser.cpp +++ b/src/core/ConfigParser.cpp @@ -91,6 +91,14 @@ int ConfigParser::parse_file(std::string file_name, Configuration& config) IniFile ini_file = ini_parser.get_parsed_ini_file(); + // Expand FIP macros (@use / [@page] / @parameter) into concrete [page]/[layer] + // sections before the normal section processing below runs on them. + if (expand_macros(ini_file, config) != EXIT_SUCCESS) + { + Logger(TLogLevel::logERROR) << "parser: error while expanding macros in config file: " << file_name << std::endl; + return EXIT_FAILURE; + } + int error_count = 0; for (auto& ini_section : ini_file.sections) { @@ -236,7 +244,14 @@ int ConfigParser::process_fip_layer_section(IniFileSection& section, Configurati //fip-images/Adf_Kompass_Ring.bmp,ref_x:0,ref_y:0,base_rot:0 if (m.size() >= 7) { - std::filesystem::path bmp_file_absolute_path = std::filesystem::path(config.aircraft_path); + // Macro-expanded layers carry an explicit base dir (the macro folder) so their + // bmp assets resolve there instead of the aircraft folder. The base dir travels + // as a header property (not through tokenize) so a Windows path's drive-letter + // colon can't be mistaken for a token separator. + std::filesystem::path bmp_base_dir = section.header.properties.count(MACRO_BASE_DIR_PROPERTY) > 0 + ? std::filesystem::path(section.header.properties[MACRO_BASE_DIR_PROPERTY]) + : std::filesystem::path(config.aircraft_path); + std::filesystem::path bmp_file_absolute_path = bmp_base_dir; bmp_file_absolute_path /= std::string(m[0]); int ref_x = 0; @@ -294,6 +309,244 @@ int ConfigParser::process_fip_layer_section(IniFileSection& section, Configurati return EXIT_SUCCESS; } +// Replace every @{name} occurrence in s with its bound value. Returns false and sets +// 'missing' if a placeholder has no binding (or is malformed), leaving s partially expanded. +static bool substitute_in_string(std::string& s, const std::map& bindings, std::string& missing) +{ + size_t pos = 0; + while ((pos = s.find("@{", pos)) != std::string::npos) + { + size_t end = s.find('}', pos + 2); + if (end == std::string::npos) + { + missing = s.substr(pos); + return false; + } + std::string name = s.substr(pos + 2, end - (pos + 2)); + auto it = bindings.find(name); + if (it == bindings.end()) + { + missing = name; + return false; + } + s.replace(pos, end - pos + 1, it->second); + pos += it->second.size(); + } + return true; +} + +// Parse an @parameter value of the form "name:,value:" into its name and value parts. +// The value part is taken verbatim (everything after ",value:") so its own ':'/',' survive. +bool ConfigParser::parse_macro_parameter(const std::string& raw, std::string& out_name, std::string& out_value) +{ + const std::string name_prefix = "name:"; + const std::string value_delim = ",value:"; + + if (raw.rfind(name_prefix, 0) != 0) + return false; + + size_t vpos = raw.find(value_delim); + if (vpos == std::string::npos || vpos < name_prefix.size()) + return false; + + out_name = raw.substr(name_prefix.size(), vpos - name_prefix.size()); + out_value = raw.substr(vpos + value_delim.size()); + + return !out_name.empty() && !out_value.empty(); +} + +int ConfigParser::substitute_placeholders_in_section(IniFileSection& section, + const std::map& bindings, + const std::string& macro_name, const std::string& page_id) +{ + std::string missing; + for (auto& key_value : section.key_value_pairs) + { + if (!substitute_in_string(key_value.second, bindings, missing)) + { + Logger(logERROR) << "parser: macro '" << macro_name << "' page '" << page_id << "': unbound parameter '" << missing << "'" << std::endl; + return EXIT_FAILURE; + } + } + for (auto& prop : section.header.properties) + { + if (!substitute_in_string(prop.second, bindings, missing)) + { + Logger(logERROR) << "parser: macro '" << macro_name << "' page '" << page_id << "': unbound parameter '" << missing << "'" << std::endl; + return EXIT_FAILURE; + } + } + return EXIT_SUCCESS; +} + +// Load /macros/.macro, and for each [page] it defines emit a cloned +// [page] section followed by its [layer] sections with @{...} placeholders substituted from +// the matching [@page] binding block. The concrete sections are appended to 'output'. +int ConfigParser::load_and_expand_macro(const std::string& macro_name, + const std::map>& page_bindings, + Configuration& config, std::vector& output) +{ + std::filesystem::path macro_dir = std::filesystem::path(config.plugin_path) / "macros"; + std::filesystem::path macro_file = macro_dir / (macro_name + ".macro"); + + std::ifstream input_file(macro_file); + if (!input_file.is_open()) + { + Logger(logERROR) << "parser: cannot open macro file: " << macro_file.string() << std::endl; + return EXIT_FAILURE; + } + + IniFileParser macro_parser; + macro_parser.parse(input_file, macro_file.string()); + input_file.close(); + + if (macro_parser.get_number_of_errors() > 0) + { + Logger(logERROR) << "parser: error parsing macro file: " << macro_file.string() << std::endl; + return EXIT_FAILURE; + } + + IniFile macro_ini = macro_parser.get_parsed_ini_file(); + + std::string current_page_id = ""; + bool have_page = false; + const std::map empty_bindings; + const std::map* active_bindings = &empty_bindings; + + for (auto& section : macro_ini.sections) + { + if (section.header.name == TOKEN_SECTION_MACRO_INFO) + { + for (auto& key_value : section.key_value_pairs) + { + if (key_value.first == TOKEN_MACRO_DEVICE && key_value.second != "fip") + { + Logger(logERROR) << "parser: macro '" << macro_name << "' targets device '" << key_value.second << "'. Only 'fip' macros are supported" << std::endl; + return EXIT_FAILURE; + } + } + } + else if (section.header.name == TOKEN_FIP_PAGE) + { + current_page_id = section.header.id; + have_page = true; + auto it = page_bindings.find(current_page_id); + active_bindings = (it != page_bindings.end()) ? &it->second : &empty_bindings; + + IniFileSection page_section = section; + if (substitute_placeholders_in_section(page_section, *active_bindings, macro_name, current_page_id) != EXIT_SUCCESS) + return EXIT_FAILURE; + output.push_back(page_section); + } + else if (section.header.name == TOKEN_FIP_LAYER) + { + if (!have_page) + { + Logger(logERROR) << "parser: macro '" << macro_name << "' has a [layer] before any [page]" << std::endl; + return EXIT_FAILURE; + } + + IniFileSection layer_section = section; + if (substitute_placeholders_in_section(layer_section, *active_bindings, macro_name, current_page_id) != EXIT_SUCCESS) + return EXIT_FAILURE; + + // tell process_fip_layer_section to resolve this layer's bmp against the macro folder + layer_section.header.properties[MACRO_BASE_DIR_PROPERTY] = macro_dir.string(); + output.push_back(layer_section); + } + else if (section.header.name == "") + { + // synthetic root section of the macro file - nothing to emit + continue; + } + else + { + Logger(logERROR) << "parser: unsupported section '" << section.header.name << "' in macro '" << macro_name << "'" << std::endl; + return EXIT_FAILURE; + } + } + + return EXIT_SUCCESS; +} + +// Rewrite ini_file.sections in place: expand any '@use' referenced macro inside a [screen] +// section (bound by the following [@page]/@parameter blocks) into concrete [page]/[layer] +// sections. Non-macro sections pass through unchanged. +int ConfigParser::expand_macros(IniFile& ini_file, Configuration& config) +{ + std::vector output; + std::vector& sections = ini_file.sections; + + for (size_t i = 0; i < sections.size(); ) + { + IniFileSection& section = sections[i]; + + if (section.header.name == TOKEN_SECTION_MACRO_PAGE) + { + Logger(logERROR) << "parser: [@page:...] binding block without a preceding [screen] that uses a macro. section at line " << section.header.line << std::endl; + return EXIT_FAILURE; + } + + if (section.header.name == TOKEN_SECTION_FIP_SCREEN) + { + // pull the @use macro names out of the screen section, keep the rest + std::vector macro_names; + std::vector> kept_key_values; + for (auto& key_value : section.key_value_pairs) + { + if (key_value.first == TOKEN_MACRO_USE) + macro_names.push_back(key_value.second); + else + kept_key_values.push_back(key_value); + } + section.key_value_pairs = kept_key_values; + output.push_back(section); + i++; + + if (macro_names.empty()) + continue; + + // gather the following [@page:...] binding blocks: page id -> (param name -> value) + std::map> page_bindings; + while (i < sections.size() && sections[i].header.name == TOKEN_SECTION_MACRO_PAGE) + { + IniFileSection& binding = sections[i]; + std::map params; + for (auto& key_value : binding.key_value_pairs) + { + if (key_value.first != TOKEN_MACRO_PARAMETER) + { + Logger(logERROR) << "parser: unexpected key '" << key_value.first << "' in [@page] binding block at line " << binding.header.line << std::endl; + return EXIT_FAILURE; + } + std::string param_name, param_value; + if (!parse_macro_parameter(key_value.second, param_name, param_value)) + { + Logger(logERROR) << "parser: invalid @parameter syntax at line " << binding.header.line << ": " << key_value.second << std::endl; + return EXIT_FAILURE; + } + params[param_name] = param_value; + } + page_bindings[binding.header.id] = params; + i++; + } + + for (auto& macro_name : macro_names) + { + if (load_and_expand_macro(macro_name, page_bindings, config, output) != EXIT_SUCCESS) + return EXIT_FAILURE; + } + continue; + } + + output.push_back(section); + i++; + } + + sections = output; + return EXIT_SUCCESS; +} + int ConfigParser::handle_on_vid(IniFileSectionHeader section_header, std::string key, std::string value, Configuration& config) { (void)section_header; diff --git a/src/core/ConfigParser.h b/src/core/ConfigParser.h index 937351e..3a0c856 100644 --- a/src/core/ConfigParser.h +++ b/src/core/ConfigParser.h @@ -23,6 +23,15 @@ class ConfigParser int process_fip_layer_section(IniFileSection& section, Configuration& config); + int expand_macros(IniFile& ini_file, Configuration& config); + int load_and_expand_macro(const std::string& macro_name, + const std::map>& page_bindings, + Configuration& config, std::vector& output); + int substitute_placeholders_in_section(IniFileSection& section, + const std::map& bindings, + const std::string& macro_name, const std::string& page_id); + bool parse_macro_parameter(const std::string& raw, std::string& out_name, std::string& out_value); + int handle_on_push_or_release(IniFileSectionHeader section_header, std::string key, std::string value, Configuration& config); int handle_on_lit_or_unlit_or_blink(IniFileSectionHeader section_header, std::string key, std::string value, Configuration& config); int handle_on_dynamic_speed(IniFileSectionHeader section_header, std::string key, std::string value, Configuration& config); @@ -69,6 +78,13 @@ class ConfigParser const std::string TOKEN_FIP_OFFSET_Y = "offset_y"; const std::string TOKEN_FIP_ROTATION = "rotation"; + const std::string TOKEN_MACRO_USE = "@use"; + const std::string TOKEN_SECTION_MACRO_PAGE = "@page"; + const std::string TOKEN_MACRO_PARAMETER = "@parameter"; + const std::string TOKEN_SECTION_MACRO_INFO = "macro_info"; + const std::string TOKEN_MACRO_DEVICE = "device"; + const std::string MACRO_BASE_DIR_PROPERTY = "macro_base_dir"; + const std::string DEVICE_TYPE_SAITEK_MULTI = "saitek_multi"; const std::string DEVICE_TYPE_SAITEK_RADIO = "saitek_radio"; const std::string DEVICE_TYPE_SAITEK_SWITCH = "saitek_switch"; diff --git a/src/core/IniFileParser.cpp b/src/core/IniFileParser.cpp index bbb4177..8e8a7a1 100644 --- a/src/core/IniFileParser.cpp +++ b/src/core/IniFileParser.cpp @@ -137,7 +137,7 @@ IniFileParser::Token IniFileParser::lex_token(std::string& value) case 'M': case 'N': case 'O': case 'P': case 'Q': case 'R': case 'S': case 'T': case 'U': case 'V': case 'W': case 'X': case 'Y': case 'Z': - case '_': case '.': + case '_': case '.': case '@': value.push_back(c); return lex_identifier(value); case '"': @@ -248,7 +248,7 @@ IniFileParser::Token IniFileParser::lex_identifier(std::string& value) case 'M': case 'N': case 'O': case 'P': case 'Q': case 'R': case 'S': case 'T': case 'U': case 'V': case 'W': case 'X': case 'Y': case 'Z': - case '_': case '-': + case '_': case '-': case '@': value.push_back(c); break; default: diff --git a/src/core/XPanel.cpp b/src/core/XPanel.cpp index 63d1ee8..23255b4 100644 --- a/src/core/XPanel.cpp +++ b/src/core/XPanel.cpp @@ -238,6 +238,11 @@ std::filesystem::path find_config_file(const std::string& aircraft_file_name, co { ConfigParser temp_parser; Configuration temp_config; + // Provide the same paths the real parse gets so configs that reference + // bmp assets or FIP macros parse successfully during aircraft matching + // instead of being skipped. + temp_config.plugin_path = plugin_path.string(); + temp_config.aircraft_path = aircraft_file_path; if (temp_parser.parse_file(entry.path().string(), temp_config) == EXIT_SUCCESS) { if (temp_config.aircraft_acf == aircraft_file_name) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bc551e0..a5d9833 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -44,6 +44,7 @@ set(XPANEL_TEST_SRCS test_trc1000_audio.cpp test_trc1000_pfd.cpp test_xpanel_plugin.cpp + test_fip_macro.cpp ) add_executable(xpanel_tests ${XPANEL_TEST_SRCS}) @@ -86,6 +87,7 @@ target_link_libraries(xpanel_tests PRIVATE Lua::Lua doctest::doctest) # this directory's fixture files. file(COPY ${CMAKE_SOURCE_DIR}/sample-config/board-config.ini DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) file(COPY ${CMAKE_SOURCE_DIR}/test/fip-images/ DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/fip-images) +file(COPY ${CMAKE_SOURCE_DIR}/test/macros/ DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/macros) file(COPY ${CMAKE_SOURCE_DIR}/3rdparty/FIP-SDK/fonts/fip-fonts.bmp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) set(_test_wd ${CMAKE_CURRENT_BINARY_DIR}) @@ -102,3 +104,4 @@ add_test(NAME test_radio_panel COMMAND xpanel_tests --test-suite=test_radi add_test(NAME test_trc1000_audio COMMAND xpanel_tests --test-suite=test_trc1000_audio_panel WORKING_DIRECTORY ${_test_wd}) add_test(NAME test_trc1000_pfd COMMAND xpanel_tests --test-suite=test_trc1000_pfd_panel WORKING_DIRECTORY ${_test_wd}) add_test(NAME test_xpanel_plugin COMMAND xpanel_tests --test-suite=test_plugin WORKING_DIRECTORY ${_test_wd}) +add_test(NAME test_fip_macro COMMAND xpanel_tests --test-suite=test_fip_macro WORKING_DIRECTORY ${_test_wd}) diff --git a/test/macros/fip-images/bmp_test_padding.bmp b/test/macros/fip-images/bmp_test_padding.bmp new file mode 100644 index 0000000..0dc8bd6 Binary files /dev/null and b/test/macros/fip-images/bmp_test_padding.bmp differ diff --git a/test/macros/test_instrument.macro b/test/macros/test_instrument.macro new file mode 100644 index 0000000..4c946eb --- /dev/null +++ b/test/macros/test_instrument.macro @@ -0,0 +1,26 @@ +; Copyright (C) 2026 Norbert Takacs +; SPDX-License-Identifier: GPL-3.0-or-later +; +; Test macro fixture for the FIP macro expansion unit tests. + +[macro_info] +name="test_instrument" +description="Test instrument macro" +device="fip" +version="1.0" + +[page:id="HSI"] + [layer:image="fip-images/bmp_test_padding.bmp,ref_x:0,ref_y:0,base_rot:0"] + rotation="@{course}" + offset_x="const:200" + offset_y="const:120" + + [layer:type="text"] + offset_x="const:38" + offset_y="const:218" + text="@{baro}" + +[page:id="STATIC"] + [layer:image="fip-images/bmp_test_padding.bmp,ref_x:0,ref_y:0,base_rot:0"] + offset_x="const:0" + offset_y="const:0" diff --git a/test/test-fip-macro-config.ini b/test/test-fip-macro-config.ini new file mode 100644 index 0000000..31a4045 --- /dev/null +++ b/test/test-fip-macro-config.ini @@ -0,0 +1,18 @@ +; Copyright (C) 2026 Norbert Takacs +; SPDX-License-Identifier: GPL-3.0-or-later +; +; FIP macro expansion test config: uses the test_instrument macro and binds its +; HSI page parameters. The STATIC page has no parameters, so it needs no binding block. + +log_level="TRACE" +aircraft_acf="generic.acf" + +[device:id="saitek_fip_screen"] +serial="MZB05779E2" + + [screen:id="fip-screen"] + @use="test_instrument" + + [@page:id="HSI"] + @parameter="name:course,value:dataref:sim/test/course,scale:-1.0" + @parameter="name:baro,value:lua:get_baro()" diff --git a/test/test-fip-macro-unbound-config.ini b/test/test-fip-macro-unbound-config.ini new file mode 100644 index 0000000..1392a2f --- /dev/null +++ b/test/test-fip-macro-unbound-config.ini @@ -0,0 +1,17 @@ +; Copyright (C) 2026 Norbert Takacs +; SPDX-License-Identifier: GPL-3.0-or-later +; +; Negative test config: uses the test_instrument macro but leaves the 'baro' +; parameter of the HSI page unbound, which must make parsing fail. + +log_level="TRACE" +aircraft_acf="generic.acf" + +[device:id="saitek_fip_screen"] +serial="MZB05779E2" + + [screen:id="fip-screen"] + @use="test_instrument" + + [@page:id="HSI"] + @parameter="name:course,value:dataref:sim/test/course,scale:-1.0" diff --git a/test/test.vcxproj b/test/test.vcxproj index da3bd22..77b9144 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -231,6 +231,7 @@ xcopy /y /d "$(SolutionDir)3rdparty\FIP-SDK\fonts\fip-fonts.bmp" "$(OutDir)" + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index ba3ddf7..7186a36 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -36,6 +36,9 @@ Source Files + + Source Files + Source Files diff --git a/test/test_fip_macro.cpp b/test/test_fip_macro.cpp new file mode 100644 index 0000000..85d6200 --- /dev/null +++ b/test/test_fip_macro.cpp @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Norbert Takacs + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include "XPLMDefs.h" +#include "XPLMPlanes.h" +#include "core/ConfigParser.h" +#include "fip/FIPScreen.h" + +#include "CppUnitTest.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +void test_set_aircraft_path_and_filename(char* file_name, char* path); + +namespace test +{ + // The FIP macro feature expands `@use` + `[@page]`/`@parameter` bindings into ordinary + // [page]/[layer] sections. A successful parse already proves the macro file was found and + // its bmp assets resolved against the macro folder (add_layer_to_page fails the parse + // otherwise); the assertions below check the expanded pages/layers landed as expected. + TEST_CLASS(test_fip_macro) + { + public: + TEST_METHOD(TestMacroExpansion) + { + test_set_aircraft_path_and_filename(const_cast("generic.acf"), const_cast("./")); + + Configuration config; + // plugin_path left default "" -> macros resolve under the test working dir + // (build/test), where CMake copies test/macros/. Mirrors how the existing FIP + // test resolves fip-fonts.bmp relative to the working dir. + + ConfigParser parser; + int result = parser.parse_file("../../test/test-fip-macro-config.ini", config); + Assert::AreEqual(0, result); + + Assert::AreEqual(1, (int)config.class_configs.size()); + FIPScreen* screen = config.class_configs[0].fip_screens["saitek_fip_screen"]; + Assert::IsTrue(screen != nullptr); + + // the macro defined two pages, in order: HSI (index 0) and STATIC (index 1) + Assert::AreEqual(1, screen->get_last_page_index()); + Assert::AreEqual(std::string("HSI"), screen->get_page_name(0)); + Assert::AreEqual(std::string("STATIC"), screen->get_page_name(1)); + + // HSI page: one image layer + one text layer -> last index 1 + Assert::AreEqual(1, screen->get_last_layer_index(0)); + // STATIC page: one image layer -> last index 0 + Assert::AreEqual(0, screen->get_last_layer_index(1)); + } + + TEST_METHOD(TestMacroUnboundParameterFails) + { + test_set_aircraft_path_and_filename(const_cast("generic.acf"), const_cast("./")); + + Configuration config; + + ConfigParser parser; + // the 'baro' parameter of the HSI page is left unbound -> parsing must fail + int result = parser.parse_file("../../test/test-fip-macro-unbound-config.ini", config); + Assert::AreEqual(1, result); + } + }; +}