diff --git a/feature_integration_tests/test_cases/BUILD b/feature_integration_tests/test_cases/BUILD index 51378eeae80..3574eec2a89 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -46,6 +46,7 @@ score_py_pytest( data = [ "conftest.py", "fit_scenario.py", + "persistency_scenario.py", "test_properties.py", "//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", ], @@ -67,6 +68,7 @@ score_py_pytest( data = [ "conftest.py", "fit_scenario.py", + "persistency_scenario.py", "test_properties.py", "//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", ], diff --git a/feature_integration_tests/test_cases/fit_scenario.py b/feature_integration_tests/test_cases/fit_scenario.py index 3452745498e..f26bfc06674 100644 --- a/feature_integration_tests/test_cases/fit_scenario.py +++ b/feature_integration_tests/test_cases/fit_scenario.py @@ -11,8 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* import shutil -from collections.abc import Generator from pathlib import Path +from typing import Generator import pytest from testing_utils import ( @@ -64,7 +64,7 @@ def temp_dir_common( class FitScenario(Scenario): """ - CIT test scenario definition. + FIT test scenario definition. """ @pytest.fixture(scope="class") @@ -90,10 +90,9 @@ def results( ) -> ScenarioResult: result = self._run_command(command, execution_timeout, args, kwargs) success = result.return_code == ResultCode.SUCCESS and not result.hang - expect_failure = self.expect_command_failure() - if expect_failure and success: + if self.expect_command_failure() and success: raise RuntimeError(f"Command execution succeeded unexpectedly: {result=}") - if not expect_failure and not success: + if not self.expect_command_failure() and not success: raise RuntimeError(f"Command execution failed unexpectedly: {result=}") return result diff --git a/feature_integration_tests/test_cases/persistency_scenario.py b/feature_integration_tests/test_cases/persistency_scenario.py new file mode 100644 index 00000000000..e21b5f6651a --- /dev/null +++ b/feature_integration_tests/test_cases/persistency_scenario.py @@ -0,0 +1,150 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +""" +Helpers and base scenario class for persistency feature integration tests. + +``create_kvs_defaults_file`` and ``read_kvs_snapshot`` provide the file-system +operations that test methods use to set up and inspect KVS state. +``PersistencyScenario`` is a :class:`FitScenario` subclass that supplies the +shared ``temp_dir`` fixture so individual test classes do not have to duplicate it. +""" + +import json +from collections.abc import Generator +from pathlib import Path +from zlib import adler32 + +import pytest +from fit_scenario import FitScenario, temp_dir_common + + +def create_kvs_defaults_file(dir_path: Path, instance_id: int, values: dict) -> Path: + """ + Create a KVS defaults JSON file and matching hash file at conventional paths. + + KVS expects defaults at: {dir}/kvs_{instance_id}_default.json + and the hash at: {dir}/kvs_{instance_id}_default.hash + + The JSON format is: {"key": {"t": "type_tag", "v": value}, ...} + The hash is adler32 of the JSON string, written as 4 big-endian bytes. + + Parameters + ---------- + dir_path : Path + Working directory for the KVS instance. + instance_id : int + KVS instance identifier. + values : dict + Mapping of key -> (type_tag, value), e.g. {"my_key": ("f64", 1.0)}. + + Returns + ------- + Path + Path to the created JSON defaults file. + """ + json_path = dir_path / f"kvs_{instance_id}_default.json" + hash_path = dir_path / f"kvs_{instance_id}_default.hash" + + data = {key: {"t": type_tag, "v": val} for key, (type_tag, val) in values.items()} + json_str = json.dumps(data) + + json_path.write_text(json_str) + hash_path.write_bytes(adler32(json_str.encode()).to_bytes(length=4, byteorder="big")) + return json_path + + +def read_kvs_snapshot(dir_path: Path, instance_id: int, snapshot_id: int = 0) -> dict: + """ + Read and parse the KVS snapshot JSON for a given instance. + + Supports both the Rust/normalized envelope format {"t":"obj","v":{...}} + and the raw C++ format {key: {...}}. Returns the inner key -> tagged-value mapping. + + Parameters + ---------- + dir_path : Path + Working directory containing the KVS snapshot files. + instance_id : int + KVS instance identifier used in the filename convention. + snapshot_id : int, optional + Snapshot sequence number (default 0). + + Returns + ------- + dict + Mapping of key -> tagged-value dict, e.g. {"mykey": {"t": "f64", "v": 1.0}}. + """ + path = dir_path / f"kvs_{instance_id}_{snapshot_id}.json" + data = json.loads(path.read_text()) + if isinstance(data, dict) and data.get("t") == "obj" and "v" in data: + return data["v"] + return data + + +def verify_kvs_snapshot_hash(dir_path: Path, instance_id: int, snapshot_id: int = 0) -> None: + """ + Assert that the snapshot hash file content matches the Adler-32 of the JSON file. + + After ``normalize_snapshot_file_to_rust_envelope`` rewrites the JSON, the + companion ``.hash`` file must also be rewritten. This helper detects any + mismatch between the two, catching stale hashes introduced by manual or + tool-driven JSON modifications. + + Parameters + ---------- + dir_path : Path + Working directory containing the KVS snapshot files. + instance_id : int + KVS instance identifier used in the filename convention. + snapshot_id : int, optional + Snapshot sequence number (default 0). + """ + json_path = dir_path / f"kvs_{instance_id}_{snapshot_id}.json" + hash_path = dir_path / f"kvs_{instance_id}_{snapshot_id}.hash" + json_bytes = json_path.read_bytes() + expected = adler32(json_bytes).to_bytes(4, byteorder="big") + actual = hash_path.read_bytes() + assert actual == expected, ( + f"Hash mismatch for kvs_{instance_id}_{snapshot_id}: " + f"hash file contains {actual.hex()} but Adler-32 of the JSON is {expected.hex()}" + ) + + +class PersistencyScenario(FitScenario): + """ + Base class for persistency feature integration tests. + + Provides the ``temp_dir`` fixture shared by all persistency test classes, + avoiding fixture duplication across subclasses. + """ + + @pytest.fixture(scope="class") + def temp_dir( + self, + tmp_path_factory: pytest.TempPathFactory, + version: str, + ) -> Generator[Path, None, None]: + """ + Provide a temporary working directory for the KVS instance. + + The directory is named after the test class and parametrized version, + and is automatically removed after the test class completes. + + Parameters + ---------- + tmp_path_factory : pytest.TempPathFactory + Built-in pytest factory for temporary directories. + version : str + Parametrized scenario version (``"rust"`` or ``"cpp"``). + """ + yield from temp_dir_common(tmp_path_factory, self.__class__.__name__, version) diff --git a/feature_integration_tests/test_cases/tests/persistency/test_combined_requirements.py b/feature_integration_tests/test_cases/tests/persistency/test_combined_requirements.py new file mode 100644 index 00000000000..ecb12666948 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/persistency/test_combined_requirements.py @@ -0,0 +1,380 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +""" +Creative FIT tests that mix and match persistency requirements. + +Each test exercises multiple requirements together through a single observable +storage outcome rather than testing each requirement in isolation. +""" + +from math import isclose +from pathlib import Path +from typing import Any + +import pytest +from fit_scenario import ResultCode +from persistency_scenario import ( + PersistencyScenario, + create_kvs_defaults_file, + read_kvs_snapshot, + verify_kvs_snapshot_hash, +) +from test_properties import add_test_properties +from testing_utils import ScenarioResult + +pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") + + +# --------------------------------------------------------------------------- +# Scenario 1: Mixed value types with UTF-8 keys in one snapshot +# --------------------------------------------------------------------------- + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__support_datatype_keys", + "feat_req__persistency__support_datatype_value", + "feat_req__persistency__store_data", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestAllTypesWithUtf8Keys(PersistencyScenario): + """ + Verify that KVS can store multiple value types simultaneously under both + ASCII and UTF-8 encoded key names, and that all of them are physically + present in the single persisted snapshot file. + + This combines key-encoding support (UTF-8) with value-type coverage + (i32, f64, bool, str, null) in one observable storage outcome. + """ + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.supported_datatypes.all_types_utf8" + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + }, + }, + } + + def test_value_types_persisted(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + Each key must carry the correct KVS type tag in the snapshot, + confirming that value type information survives the flush cycle. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + + assert snapshot["ascii_i32"]["t"] == "i32" + assert snapshot["ascii_i32"]["v"] == -321 + + assert snapshot["emoji_f64 🎯"]["t"] == "f64" + assert isclose(snapshot["emoji_f64 🎯"]["v"], 3.14, abs_tol=1e-4) + + assert snapshot["greek_bool αβγ"]["t"] == "bool" + assert snapshot["greek_bool αβγ"]["v"] is True + + assert snapshot["ascii_str"]["t"] == "str" + assert snapshot["ascii_str"]["v"] == "hello" + + assert snapshot["ascii_null"]["t"] == "null" + assert snapshot["ascii_null"]["v"] is None + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + +# --------------------------------------------------------------------------- +# Scenario 2: Partial override — only explicitly written keys enter snapshot +# --------------------------------------------------------------------------- + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + "feat_req__persistency__store_data", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestPartialOverrideSnapshot(PersistencyScenario): + """ + Verify that when a KVS instance has default values for three keys but only + one key is explicitly overridden, the snapshot contains ONLY the overridden + key. The two untouched keys remain as defaults and are absent from storage. + + This tests the boundary between default values (in-memory read-only) and + persisted values (explicit flush), combining default_values, + default_value_file, and store_data requirements. + """ + + _DEFAULT_VALUE = 50.0 + _OVERRIDE_VALUE = 999.0 + _KEYS = ["partial_key_0", "partial_key_1", "partial_key_2"] + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.partial_override" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """Create defaults for all three keys.""" + return create_kvs_defaults_file( + temp_dir, + 1, + {key: ("f64", self._DEFAULT_VALUE) for key in self._KEYS}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + # defaults_file dependency ensures the file is created before the scenario runs. + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_only_overridden_key_in_snapshot(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + The snapshot must contain exactly partial_key_1 (the explicitly set key). + partial_key_0 and partial_key_2 were never written, so they must be absent. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + + # Explicitly overridden key must be present with the override value. + assert "partial_key_1" in snapshot, "Overridden key must be present in snapshot" + assert isclose(snapshot["partial_key_1"]["v"], self._OVERRIDE_VALUE, abs_tol=1e-4) + + # Default-only keys must NOT appear in the snapshot. + assert "partial_key_0" not in snapshot, "Default-only key partial_key_0 must be absent from snapshot" + assert "partial_key_2" not in snapshot, "Default-only key partial_key_2 must be absent from snapshot" + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + def test_default_values_accessible(self, results: ScenarioResult, logs_info_level: Any) -> None: + """ + Verify that the default values for partial_key_0 and partial_key_2 are + accessible via get_value even though they were never explicitly written. + + Checks structured log fields emitted by the scenario after flush. + """ + assert results.return_code == ResultCode.SUCCESS + log0 = logs_info_level.find_log("key", value="partial_key_0") + assert log0 is not None, "Expected log entry for default partial_key_0" + assert isclose(float(log0.value), self._DEFAULT_VALUE, abs_tol=1e-4), ( + f"Expected partial_key_0 default ≈ {self._DEFAULT_VALUE}, got {log0.value}" + ) + log2 = logs_info_level.find_log("key", value="partial_key_2") + assert log2 is not None, "Expected log entry for default partial_key_2" + assert isclose(float(log2.value), self._DEFAULT_VALUE, abs_tol=1e-4), ( + f"Expected partial_key_2 default ≈ {self._DEFAULT_VALUE}, got {log2.value}" + ) + + +# --------------------------------------------------------------------------- +# Scenario 3: UTF-8 keys in defaults file + selective override +# --------------------------------------------------------------------------- + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__support_datatype_keys", + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestUtf8KeysWithDefaults(PersistencyScenario): + """ + Verify that UTF-8 encoded key names work correctly as keys in both the + defaults file and the KVS snapshot. + + The defaults file contains three keys with UTF-8 names (ASCII, emoji, + Greek script). The scenario overrides only the emoji key. Python verifies: + - the emoji key appears in the snapshot with the override value + - the ASCII and Greek default-only keys are absent from the snapshot + + This combines key-encoding (support_datatype_keys) with default value + provisioning (default_values + default_value_file) in one storage outcome. + """ + + _KEY_ASCII = "utf8_ascii_key" + _KEY_EMOJI = "utf8_emoji 🔑" + _KEY_GREEK = "utf8_greek κλμ" + _DEFAULT_VALUE = 42.0 + _OVERRIDE_VALUE = 777.0 + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.utf8_defaults" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """Create defaults using UTF-8 key names.""" + return create_kvs_defaults_file( + temp_dir, + 1, + { + self._KEY_ASCII: ("f64", self._DEFAULT_VALUE), + self._KEY_EMOJI: ("f64", self._DEFAULT_VALUE), + self._KEY_GREEK: ("f64", self._DEFAULT_VALUE), + }, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_emoji_override_persisted(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + The explicitly overridden UTF-8 emoji key must appear in the snapshot + with the override value, confirming that UTF-8 key names survive the + full round-trip through the defaults file and snapshot storage. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + + assert self._KEY_EMOJI in snapshot, ( + f"Overridden UTF-8 emoji key '{self._KEY_EMOJI}' must be present in snapshot" + ) + assert isclose(snapshot[self._KEY_EMOJI]["v"], self._OVERRIDE_VALUE, abs_tol=1e-4) + + def test_default_only_utf8_keys_absent(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + UTF-8 keys that were not explicitly overridden must remain absent from + the snapshot, demonstrating that defaults do not pollute persisted storage. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + + assert self._KEY_ASCII not in snapshot, ( + f"Default-only ASCII key '{self._KEY_ASCII}' must be absent from snapshot" + ) + assert self._KEY_GREEK not in snapshot, ( + f"Default-only Greek key '{self._KEY_GREEK}' must be absent from snapshot" + ) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + def test_utf8_default_values_accessible(self, results: ScenarioResult, logs_info_level: Any) -> None: + """ + Verify that default values behind UTF-8 ASCII and Greek keys are accessible + via get_value even though they were never explicitly written. + + Checks structured log fields emitted by the scenario after flush. + """ + assert results.return_code == ResultCode.SUCCESS + log_ascii = logs_info_level.find_log("key", value="utf8_ascii_key") + assert log_ascii is not None, "Expected log entry for default utf8_ascii_key" + assert isclose(float(log_ascii.value), self._DEFAULT_VALUE, abs_tol=1e-4) + log_greek = logs_info_level.find_log("key", value="utf8_greek κλμ") + assert log_greek is not None, "Expected log entry for default utf8_greek κλμ" + assert isclose(float(log_greek.value), self._DEFAULT_VALUE, abs_tol=1e-4) + + +# --------------------------------------------------------------------------- +# Scenario 4: UTF-8 key in defaults file + get_value without set_value +# --------------------------------------------------------------------------- + + +@add_test_properties( + fully_verifies=["feat_req__persistency__default_value_get"], + partially_verifies=["feat_req__persistency__support_datatype_keys"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestUtf8DefaultValueGet(PersistencyScenario): + """ + Verify that get_value retrieves the correct default for a UTF-8 emoji key + that was provisioned in the defaults file but never explicitly set. + + The scenario reads the default via the UTF-8 key, writes the result to an + ASCII probe key, and flushes. Python checks the probe key equals the expected + default — combining feat_req__persistency__default_value_get with + feat_req__persistency__support_datatype_keys in one storage outcome. + """ + + _GET_KEY = "probe 🔍" + _GET_DEFAULT_VALUE = 42.0 + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.utf8_default_value_get" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """Provision a default value behind a UTF-8 emoji key.""" + return create_kvs_defaults_file( + temp_dir, + 1, + {self._GET_KEY: ("f64", self._GET_DEFAULT_VALUE)}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_utf8_default_value_readable(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + The ASCII probe key written by the scenario must equal the UTF-8 key's + default, confirming that get_value works correctly with UTF-8 default keys. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + assert "result_key" in snapshot, "Probe key 'result_key' must be present in snapshot" + assert isclose(snapshot["result_key"]["v"], self._GET_DEFAULT_VALUE, abs_tol=1e-4), ( + f"Expected probe key value ≈ {self._GET_DEFAULT_VALUE}, got {snapshot['result_key']['v']}" + ) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) diff --git a/feature_integration_tests/test_cases/tests/persistency/test_datatype_support.py b/feature_integration_tests/test_cases/tests/persistency/test_datatype_support.py new file mode 100644 index 00000000000..eca0b55d9d4 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/persistency/test_datatype_support.py @@ -0,0 +1,131 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from math import isclose +from pathlib import Path +from typing import Any + +import pytest +from fit_scenario import ResultCode +from persistency_scenario import PersistencyScenario, read_kvs_snapshot, verify_kvs_snapshot_hash +from test_properties import add_test_properties +from testing_utils import ScenarioResult + +pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") + + +class SupportedDatatypesScenario(PersistencyScenario): + """Common base for supported datatypes scenarios.""" + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + }, + }, + } + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__support_datatype_value", + "feat_req__persistency__support_datatype_keys", + "feat_req__persistency__store_data", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestAllValueTypes(SupportedDatatypesScenario): + """ + Verify that all nine KVS value types coexist in a single flushed snapshot. + + All nine types (i32, u32, i64, u64, f64, bool, str, arr, obj) are written + to one KVS instance and persisted in a single flush. Python verifies every + key is present with the correct type tag and value, confirming that multiple + value types do not interfere with each other in one atomic storage outcome. + This verifies multi-type coexistence — exercising feat_req__persistency__support_datatype_value, + feat_req__persistency__support_datatype_keys, and + feat_req__persistency__store_data together. + """ + + _EXPECTED_ALL_TYPES: dict[str, dict[str, Any]] = { + "i32_key": {"t": "i32", "v": -321}, + "u32_key": {"t": "u32", "v": 1234}, + "i64_key": {"t": "i64", "v": -123456789}, + "u64_key": {"t": "u64", "v": 123456789}, + "f64_key": {"t": "f64", "v": -5432.1}, + "bool_key": {"t": "bool", "v": True}, + "str_key": {"t": "str", "v": "example"}, + "arr_key": { + "t": "arr", + "v": [ + {"t": "f64", "v": 321.5}, + {"t": "bool", "v": False}, + {"t": "str", "v": "hello"}, + {"t": "null", "v": None}, + {"t": "arr", "v": []}, + {"t": "obj", "v": {"sub-number": {"t": "f64", "v": 789.0}}}, + ], + }, + "obj_key": {"t": "obj", "v": {"sub-number": {"t": "f64", "v": 789.0}}}, + } + + @staticmethod + def _assert_tagged_value(actual: dict[str, Any], expected: dict[str, Any]) -> None: + """Recursively compare tagged KVS values with tolerance for f64 types.""" + assert actual["t"] == expected["t"] + value_type = expected["t"] + + if value_type == "f64": + assert isclose(actual["v"], expected["v"], abs_tol=1e-4) + return + + if value_type == "arr": + assert isinstance(actual["v"], list) + assert len(actual["v"]) == len(expected["v"]) + for actual_item, expected_item in zip(actual["v"], expected["v"]): + TestAllValueTypes._assert_tagged_value(actual_item, expected_item) + return + + if value_type == "obj": + assert isinstance(actual["v"], dict) + assert set(actual["v"].keys()) == set(expected["v"].keys()) + for key, expected_item in expected["v"].items(): + TestAllValueTypes._assert_tagged_value(actual["v"][key], expected_item) + return + + assert actual["v"] == expected["v"] + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.supported_datatypes.all_value_types" + + def test_all_types_in_snapshot(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + All nine type-tagged key-value pairs must be present in the snapshot + with the correct type tags and values written by the scenario. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + + for key, expected_tagged in self._EXPECTED_ALL_TYPES.items(): + assert key in snapshot, f"Expected key '{key}' in snapshot" + self._assert_tagged_value(snapshot[key], expected_tagged) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) diff --git a/feature_integration_tests/test_cases/tests/persistency/test_default_values.py b/feature_integration_tests/test_cases/tests/persistency/test_default_values.py new file mode 100644 index 00000000000..2e92a833186 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/persistency/test_default_values.py @@ -0,0 +1,687 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import json +import re +from collections.abc import Generator +from math import isclose +from pathlib import Path +from typing import Any +from zlib import adler32 + +import pytest +from fit_scenario import FitScenario, ResultCode, temp_dir_common +from persistency_scenario import ( + PersistencyScenario, + create_kvs_defaults_file, + read_kvs_snapshot, + verify_kvs_snapshot_hash, +) +from test_properties import add_test_properties +from testing_utils import ScenarioResult + +pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") + + +@add_test_properties( + partially_verifies=["feat_req__persistency__default_values", "feat_req__persistency__default_value_get"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestDefaultValuesIgnored(PersistencyScenario): + """ + Verifies that with KvsDefaults::Ignored mode the explicitly set value is + persisted to storage and is present in the KVS snapshot. + + For Rust: also verifies that defaults are NOT loaded — get_value_as() fails + before set_value is called, as required by feat_req__persistency__default_values. + + For C++: the KvsBuilder API maps both Ignored and Optional to + need_defaults_flag(false); when a defaults file is present the KVS still loads + it. The C++ path therefore only verifies the "explicit set persists" half of + the requirement. The "not loaded" invariant is covered by the Rust path. + """ + + # Constants scoped to this class; shared with test methods. + _DEFAULT_KEY = "test_key" + _OVERRIDE_VALUE = 432.1 + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values_ignored" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """ + Create a defaults file on disk so the scenario can confirm KVS ignores it. + + Having the file present and confirming it is not loaded (Ignored mode) + is the meaningful test; without the file the ignored flag would have + nothing to ignore. + """ + return create_kvs_defaults_file( + temp_dir, + 1, + {self._DEFAULT_KEY: ("f64", 999.0)}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + # defaults_file dependency ensures the file is created before the scenario runs. + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "ignored", + }, + }, + "test": { + "key": self._DEFAULT_KEY, + "override_value": self._OVERRIDE_VALUE, + }, + } + + def test_explicit_set_persisted(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify that the explicitly set value is written to the KVS snapshot.""" + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + assert self._DEFAULT_KEY in snapshot, f"Expected key '{self._DEFAULT_KEY}' in snapshot" + assert isclose(snapshot[self._DEFAULT_KEY]["v"], self._OVERRIDE_VALUE, abs_tol=1e-5) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + +class DefaultValuesParityScenario(FitScenario): + """ + Common fixtures for default value parity scenarios. + """ + + _PARITY_KEY = "test_number" + _PARITY_DEFAULT_VALUE = 123.4 + _RESET_KEY_COUNT = 5 + _RESET_DEFAULT_BASE = 10.0 + + @staticmethod + def _reset_default_value(index: int) -> float: + """Return the default value for a given key index used in reset scenarios.""" + return DefaultValuesParityScenario._RESET_DEFAULT_BASE * (index + 1) + + @pytest.fixture(scope="class") + def temp_dir( + self, + tmp_path_factory: pytest.TempPathFactory, + version: str, + defaults: str, + ) -> Generator[Path, None, None]: + yield from temp_dir_common(tmp_path_factory, self.__class__.__name__, version, defaults) + + @pytest.fixture(scope="class") + def defaults(self, request: pytest.FixtureRequest) -> str: + """ + Provide defaults mode for parity scenarios. + """ + return getattr(request, "param", "optional") + + @pytest.fixture(scope="class") + def defaults_values(self) -> dict[str, tuple[str, float]]: + """ + Provide default values for parity scenarios. + """ + values: dict[str, tuple[str, float]] = { + self._PARITY_KEY: ("f64", self._PARITY_DEFAULT_VALUE), + } + for idx in range(self._RESET_KEY_COUNT): + values[f"{self._PARITY_KEY}_{idx}"] = ("f64", self._reset_default_value(idx)) + return values + + @pytest.fixture(scope="class") + def defaults_file( + self, + temp_dir: Path, + defaults_values: dict[str, tuple[str, float]], + defaults: str, + ) -> Path | None: + """ + Create defaults file for parity scenarios unless overridden. + """ + if defaults == "without": + return None + return create_kvs_defaults_file(temp_dir, 1, defaults_values) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults: str, defaults_file: Path | None) -> dict[str, Any]: + """ + Provide the test configuration for parity scenarios. + """ + kvs_parameters: dict[str, Any] = { + "instance_id": 1, + "dir": str(temp_dir), + } + if defaults is not None: + kvs_parameters["defaults"] = "optional" if defaults == "without" else defaults + return { + "kvs_parameters_1": { + "kvs_parameters": kvs_parameters, + }, + } + + +@pytest.mark.parametrize("defaults", ["optional", "required"], scope="class") +@add_test_properties( + partially_verifies=["feat_req__persistency__default_values"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestDefaultValuesChecksum(DefaultValuesParityScenario): + """ + Verify that the KVS snapshot checksum file matches the snapshot content. + """ + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.checksum" + + def test_checksum(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + Compare the snapshot bytes with the adler32 hash written by KVS. + Both files are at the conventional paths derived from instance_id. + """ + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestDefaultValuesMissingDefaultsFile(DefaultValuesParityScenario): + """ + Verify required defaults mode fails when defaults file is missing. + """ + + @pytest.fixture(scope="class") + def defaults(self) -> str: + return "required" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path | None: + return None + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.checksum" + + def expect_command_failure(self) -> bool: + return True + + def test_missing_defaults_file(self, results: ScenarioResult) -> None: + """ + Ensure execution fails when defaults file is missing. + """ + assert results.return_code != ResultCode.SUCCESS + + +@pytest.mark.parametrize("defaults", ["required"], scope="class") +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestDefaultValuesMalformedDefaultsFile(DefaultValuesParityScenario): + """ + Verify that KVS fails to start when the defaults file contains malformed JSON + and defaults mode is 'required'. A truncated JSON payload triggers the + JsonParserError / KvsFileReadError code path. + """ + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path | None: + """ + Create a malformed defaults file (truncated JSON) to trigger parsing errors. + """ + json_path = temp_dir / "kvs_1_default.json" + hash_path = temp_dir / "kvs_1_default.hash" + json_str = json.dumps({"test_number": {"t": "f64", "v": 123.4}})[:-2] + json_path.write_text(json_str) + hash_path.write_bytes(adler32(json_str.encode()).to_bytes(length=4, byteorder="big")) + return json_path + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.checksum" + + def expect_command_failure(self) -> bool: + return True + + def capture_stderr(self) -> bool: + return True + + def test_malformed_defaults_file(self, results: ScenarioResult) -> None: + """ + Ensure execution fails with malformed defaults file. + """ + assert results.return_code != ResultCode.SUCCESS + assert results.stderr is not None + assert re.search(r"(JsonParserError|KvsFileReadError|JSON parser error|KVS file read error)", results.stderr) + + +@add_test_properties( + fully_verifies=["feat_req__persistency__default_value_get"], + partially_verifies=[ + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestGetDefaultValue(PersistencyScenario): + """ + Verify that get_value returns the default value for a key that was + provisioned via the defaults file but never explicitly set. + + The scenario reads the default, writes it to a probe key, and flushes. + Python checks the probe key equals the expected default — the first test + in this suite that fully exercises feat_req__persistency__default_value_get. + """ + + _GET_DEFAULT_EXPECTED = 123.456 + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.get_default_value" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """Provision a default value for the probe key.""" + return create_kvs_defaults_file( + temp_dir, + 1, + {"default_probe_key": ("f64", self._GET_DEFAULT_EXPECTED)}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_default_value_readable(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + The probe key written by the scenario must equal the expected default. + This confirms the KVS returned the correct default via get_value without + any prior set_value call. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + assert "result_key" in snapshot, "Probe key 'result_key' must be present in snapshot" + assert isclose(snapshot["result_key"]["v"], self._GET_DEFAULT_EXPECTED, abs_tol=1e-4), ( + f"Expected probe key value ≈ {self._GET_DEFAULT_EXPECTED}, got {snapshot['result_key']['v']}" + ) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__reset_to_default", + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + "feat_req__persistency__store_data", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestSelectiveReset(PersistencyScenario): + """ + Verify selective reset_key: even-indexed keys revert to absent (default), + odd-indexed keys keep their override values. + + Six keys (sel_key_0 .. sel_key_5) receive optional defaults and override + values. reset_key is called on even-indexed keys (0, 2, 4). After the + second flush, even-indexed keys must be absent from the snapshot while + odd-indexed keys (1, 3, 5) must still hold their override values. + """ + + _KEY_COUNT = 6 + _DEFAULT_VALUE = 50.0 + + @staticmethod + def _override_value(index: int) -> float: + """ + Return the override value written by the scenario for the given key index. + Matches the value computed by the scenario: 100.0 * (index + 1). + """ + return 100.0 * (index + 1) + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.selective_reset" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """Provision optional defaults for all six sel_key_i keys.""" + return create_kvs_defaults_file( + temp_dir, + 1, + {f"sel_key_{i}": ("f64", self._DEFAULT_VALUE) for i in range(self._KEY_COUNT)}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_selective_reset_state(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + Even-indexed keys must be absent after reset_key; odd-indexed must retain + their override values in the final flushed snapshot. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + for i in range(self._KEY_COUNT): + key = f"sel_key_{i}" + if i % 2 == 0: + assert key not in snapshot, f"Even key '{key}' must be absent after reset_key" + else: + assert key in snapshot, f"Odd key '{key}' must be present with override" + assert isclose(snapshot[key]["v"], self._override_value(i), abs_tol=1e-4), ( + f"Expected {key} ≈ {self._override_value(i)}, got {snapshot[key]['v']}" + ) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + def test_reset_key_returns_default(self, results: ScenarioResult, logs_info_level: Any) -> None: + """ + Verify that after reset_key on sel_key_0, KVS still reports its default value + via get_value — confirming the key was reset to default rather than deleted. + Checks structured log fields emitted by the scenario after reset_key. + """ + assert results.return_code == ResultCode.SUCCESS + log = logs_info_level.find_log("key", value="sel_key_0") + assert log is not None, "Expected log entry for sel_key_0 default value after reset_key" + assert isclose(float(log.value), self._DEFAULT_VALUE, abs_tol=1e-4), ( + f"Expected sel_key_0 default ≈ {self._DEFAULT_VALUE}, got {log.value}" + ) + + +# --------------------------------------------------------------------------- +# Full reset: reset() clears all keys; subsequent writes persist correctly +# --------------------------------------------------------------------------- + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__reset_to_default", + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + "feat_req__persistency__store_data", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestFullReset(PersistencyScenario): + """ + Verify that reset() clears all previously written keys from storage + and that keys written after reset() are correctly persisted. + + Four initial keys (fr_key_0..3) are written and flushed. reset() is then + called to remove all of them. Two new keys (fr_new_0, fr_new_1) are written + and flushed. Python verifies that all four initial keys are absent and both + new keys are present with the expected values. + + This is the only FIT scenario exercising the "all keys" variant of + feat_req__persistency__reset_to_default. TestSelectiveReset covers the + single-key variant (reset_key). Together they give full coverage of the + "individual key or all keys" phrase in the requirement. + """ + + _KEY_COUNT = 4 + _NEW_KEYS = ("fr_new_0", "fr_new_1") + _NEW_VALUES = (10.0, 20.0) + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.full_reset" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """Provision optional defaults for all four initial keys.""" + return create_kvs_defaults_file( + temp_dir, + 1, + {f"fr_key_{i}": ("f64", 50.0) for i in range(self._KEY_COUNT)}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_full_reset_clears_initial_keys(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + All four initial fr_key_i keys must be absent after reset() — they were + removed by the all-keys reset, not individually. + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + for i in range(self._KEY_COUNT): + key = f"fr_key_{i}" + assert key not in snapshot, f"Initial key '{key}' must be absent after reset()" + + def test_full_reset_new_keys_present(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + Both keys written after reset() must be present in the snapshot with the + correct values, proving that subsequent writes are unaffected by reset(). + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + for key, expected in zip(self._NEW_KEYS, self._NEW_VALUES): + assert key in snapshot, f"Post-reset key '{key}' must be present in snapshot" + assert isclose(snapshot[key]["v"], expected, abs_tol=1e-4), ( + f"Expected {key} ≈ {expected}, got {snapshot[key]['v']}" + ) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + + def test_full_reset_key_returns_default(self, results: ScenarioResult, logs_info_level: Any) -> None: + """ + Verify that after reset(), fr_key_0 still returns its default value via + get_value — confirming reset() reverts keys to defaults rather than deleting them. + Checks structured log fields emitted by the scenario after reset(). + """ + assert results.return_code == ResultCode.SUCCESS + log = logs_info_level.find_log("key", value="fr_key_0") + assert log is not None, "Expected log entry for fr_key_0 default value after reset()" + assert isclose(float(log.value), 50.0, abs_tol=1e-4), f"Expected fr_key_0 default ≈ 50.0, got {log.value}" + + +# --------------------------------------------------------------------------- +# Optional mode without defaults file: graceful degradation +# --------------------------------------------------------------------------- + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__default_values", + "feat_req__persistency__default_value_file", + "feat_req__persistency__store_data", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestOptionalModeWithoutDefaults(DefaultValuesParityScenario): + """ + Verify that KVS starts and operates normally when defaults=optional but + no defaults file is present (graceful degradation). + + Unlike TestDefaultValuesMissingDefaultsFile which proves required mode fails + without a file, this test proves optional mode succeeds — the KVS initialises, + the scenario writes a key, flush completes, and the scenario returns SUCCESS. + This is a distinct code path from both ignored mode (no attempt to read the file) + and required mode (hard failure when file is absent). + """ + + @pytest.fixture(scope="class") + def defaults(self) -> str: + # "without" maps to defaults=optional in KVS config with no file created. + return "without" + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.default_values.checksum" + + def test_optional_mode_succeeds(self, results: ScenarioResult) -> None: + """Verify KVS initialises and completes successfully without a defaults file.""" + assert results.return_code == ResultCode.SUCCESS + + +# --------------------------------------------------------------------------- +# Multi-instance isolation: defaults for one instance must not leak into another +# --------------------------------------------------------------------------- + + +@add_test_properties( + partially_verifies=[ + "feat_req__persistency__default_values", + "feat_req__persistency__multiple_kvs", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestMultiInstanceDefaultIsolation(FitScenario): + """ + Verify that default values loaded for one KVS instance do not leak into a + second instance sharing the same working directory. + + kvs_1_default.json contains key_a; kvs_2_default.json contains key_b. + The scenario writes an override to each instance and flushes. Python + checks that snapshot 1 has key_a but not key_b, and snapshot 2 has key_b + but not key_a, proving per-instance defaults file isolation. + """ + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.multi_instance_isolation" + + @pytest.fixture(scope="class") + def temp_dir( + self, + tmp_path_factory: pytest.TempPathFactory, + version: str, + ) -> Generator[Path, None, None]: + yield from temp_dir_common(tmp_path_factory, self.__class__.__name__, version) + + @pytest.fixture(scope="class") + def defaults_files(self, temp_dir: Path) -> tuple[Path, Path]: + """Create separate defaults files for each instance before the scenario runs.""" + file1 = create_kvs_defaults_file(temp_dir, 1, {"key_a": ("f64", 1.0)}) + file2 = create_kvs_defaults_file(temp_dir, 2, {"key_b": ("f64", 2.0)}) + return file1, file2 + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_files: tuple[Path, Path]) -> dict[str, Any]: + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + "kvs_parameters_2": { + "kvs_parameters": { + "instance_id": 2, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + } + + def test_instance_1_snapshot_isolation(self, results: ScenarioResult, temp_dir: Path) -> None: + """Instance 1 snapshot must contain key_a and must NOT contain key_b.""" + assert results.return_code == ResultCode.SUCCESS + snapshot1 = read_kvs_snapshot(temp_dir, 1) + assert "key_a" in snapshot1, "key_a must be present in instance 1 snapshot" + assert "key_b" not in snapshot1, "key_b must not leak from instance 2 defaults into instance 1 snapshot" + + def test_instance_2_snapshot_isolation(self, results: ScenarioResult, temp_dir: Path) -> None: + """Instance 2 snapshot must contain key_b and must NOT contain key_a.""" + assert results.return_code == ResultCode.SUCCESS + snapshot2 = read_kvs_snapshot(temp_dir, 2) + assert "key_b" in snapshot2, "key_b must be present in instance 2 snapshot" + assert "key_a" not in snapshot2, "key_a must not leak from instance 1 defaults into instance 2 snapshot" + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify hash files match the Adler-32 of each instance snapshot after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) + verify_kvs_snapshot_hash(temp_dir, instance_id=2, snapshot_id=0) + + def test_default_isolation_via_logs(self, results: ScenarioResult, logs_info_level: Any) -> None: + """ + Verify that each instance's own default value is accessible and the + other instance's default key is not, as confirmed by structured log entries + emitted by the scenario before override writes. + + Instance 1 logs key_a with default value 1.0. + Instance 2 logs key_b with default value 2.0. + If either instance had cross-leaked defaults, the scenario would have + terminated with a non-zero exit code (isolation guard in scenario code). + """ + assert results.return_code == ResultCode.SUCCESS + log_a = logs_info_level.find_log("key", value="key_a") + assert log_a is not None, "Expected log entry for key_a default from instance 1" + assert isclose(float(log_a.value), 1.0, abs_tol=1e-4), f"Expected key_a default ≈ 1.0, got {log_a.value}" + log_b = logs_info_level.find_log("key", value="key_b") + assert log_b is not None, "Expected log entry for key_b default from instance 2" + assert isclose(float(log_b.value), 2.0, abs_tol=1e-4), f"Expected key_b default ≈ 2.0, got {log_b.value}" diff --git a/feature_integration_tests/test_cases/tests/persistency/test_reset_to_default.py b/feature_integration_tests/test_cases/tests/persistency/test_reset_to_default.py new file mode 100644 index 00000000000..ea3c0b00cc5 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/persistency/test_reset_to_default.py @@ -0,0 +1,123 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from math import isclose +from pathlib import Path +from typing import Any + +import pytest +from fit_scenario import ResultCode +from persistency_scenario import ( + PersistencyScenario, + create_kvs_defaults_file, + read_kvs_snapshot, + verify_kvs_snapshot_hash, +) +from test_properties import add_test_properties +from testing_utils import ScenarioResult + +pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") + + +@add_test_properties( + partially_verifies=["feat_req__persistency__reset_to_default"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestResetToDefault(PersistencyScenario): + """ + Verifies that remove_key() resets a key to default by removing it from storage. + After removing key2 and flushing: key1 and key3 remain in the snapshot with their + override values, while key2 is absent (reverts to default lookup at runtime). + """ + + _KEYS = ["key1", "key2", "key3"] + _DEFAULT_VALUES = [100.0, 200.0, 300.0] + _OVERRIDE_VALUES = [111.0, 222.0, 333.0] + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "persistency.reset_to_default" + + @pytest.fixture(scope="class") + def defaults_file(self, temp_dir: Path) -> Path: + """ + Create KVS defaults JSON and hash files at the conventional paths. + KVS finds defaults automatically by convention: kvs_{instance_id}_default.json + """ + return create_kvs_defaults_file( + temp_dir, + 1, + {key: ("f64", val) for key, val in zip(self._KEYS, self._DEFAULT_VALUES)}, + ) + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path, defaults_file: Path) -> dict[str, Any]: + # defaults_file fixture must run first to create the file before KVS initializes + return { + "kvs_parameters_1": { + "kvs_parameters": { + "instance_id": 1, + "dir": str(temp_dir), + "defaults": "optional", + }, + }, + "test": { + "keys": self._KEYS, + "override_values": self._OVERRIDE_VALUES, + "default_values": self._DEFAULT_VALUES, + }, + } + + def test_storage_state(self, results: ScenarioResult, temp_dir: Path) -> None: + """ + Verify the KVS snapshot reflects the expected state after remove_key: + - key2 (index 1) was removed and must be absent from the snapshot + - key1 and key3 remain with their override values + """ + assert results.return_code == ResultCode.SUCCESS + snapshot = read_kvs_snapshot(temp_dir, 1) + + # key2 was removed — must be absent from snapshot + assert self._KEYS[1] not in snapshot, ( + f"Reset key '{self._KEYS[1]}' should be absent from snapshot after remove_key" + ) + + # key1 and key3 remain with override values + for i, key in enumerate(self._KEYS): + if i == 1: + continue # key2 already checked above + assert key in snapshot, f"Key '{key}' should be present in snapshot" + assert isclose(snapshot[key]["v"], self._OVERRIDE_VALUES[i], abs_tol=1e-4), ( + f"Key '{key}': expected override {self._OVERRIDE_VALUES[i]}, got {snapshot[key]['v']}" + ) + + def test_default_value_reported_after_reset(self, results: ScenarioResult, logs_info_level: Any) -> None: + """ + Verify that after remove_key on key2, KVS still reports its default value + via get_value — confirming the key was reset to default rather than deleted. + + Checks structured log fields emitted by the scenario after reset. + """ + assert results.return_code == ResultCode.SUCCESS + expected_default = self._DEFAULT_VALUES[1] # key2's default is 200.0 + log = logs_info_level.find_log("key", value="key2") + assert log is not None, "Expected log entry for key2 default value after reset" + assert isclose(float(log.value), expected_default, abs_tol=1e-4), ( + f"Expected key2 default ≈ {expected_default}, got {log.value}" + ) + + def test_snapshot_hash_matches_content(self, results: ScenarioResult, temp_dir: Path) -> None: + """Verify the hash file matches the Adler-32 of the snapshot JSON after normalization.""" + assert results.return_code == ResultCode.SUCCESS + verify_kvs_snapshot_hash(temp_dir, instance_id=1, snapshot_id=0) diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_build_helpers.h b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_build_helpers.h new file mode 100644 index 00000000000..1950f989984 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_build_helpers.h @@ -0,0 +1,159 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#ifndef INTERNALS_PERSISTENCY_KVS_BUILD_HELPERS_H_ +#define INTERNALS_PERSISTENCY_KVS_BUILD_HELPERS_H_ + +#include "kvs_parameters.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace kvs_build_helpers { + +/** + * @brief Return the current UNIX timestamp as a decimal string (seconds). + * + * Used to populate the "timestamp" field in structured JSON log lines so that + * the C++ output matches the Rust tracing JSON shape expected by the FIT log + * filters. + * + * @return String containing the number of seconds since the UNIX epoch. + */ +inline std::string unix_seconds_string() { + const auto now = std::chrono::system_clock::now(); + const auto secs = + std::chrono::duration_cast(now.time_since_epoch()).count(); + return std::to_string(secs); +} + +/** + * @brief Emit a structured JSON INFO log line to stdout. + * + * Matches the Rust tracing JSON format expected by the FIT LogContainer so + * that Python test assertions can use find_log() uniformly for both Rust and + * C++ scenarios. + * + * Example output: + * @code + * {"timestamp":"1234567890","level":"INFO","fields":{"key":"my_key","value":42.0}, + * "target":"cpp_test_scenarios::scenarios::persistency::my_module","threadId":"ThreadId(1)"} + * @endcode + * + * @param fields JSON fragment for the "fields" object, e.g. @c "\"key\":\"x\",\"value\":1.0" + * @param target Module target string embedded in the log line. + */ +inline void log_info(const std::string& fields, const std::string& target) { + std::cout << "{\"timestamp\":\"" << unix_seconds_string() + << "\",\"level\":\"INFO\",\"fields\":{" << fields + << "},\"target\":\"" << target + << "\",\"threadId\":\"ThreadId(1)\"}\n"; +} + +/** + * @brief Format a double value to match Python's str(float) representation. + * + * For whole-number values (e.g. 42.0, 200.0) this appends ".0" so that the + * resulting string matches what Python's f-string interpolation produces. + * Non-integer values (e.g. 3.14) are printed as-is by the default stream. + * + * @param v Double value to format. + * @return String representation matching Python float str(). + */ +inline std::string format_double_python(double v) { + std::ostringstream oss; + oss.imbue(std::locale::classic()); // Ensure '.' decimal separator regardless of process locale. + oss << v; + std::string s = oss.str(); + if (s.find('.') == std::string::npos && s.find('e') == std::string::npos && + s.find('E') == std::string::npos) { + s += ".0"; + } + return s; +} + +/** + * @brief Convert an optional KvsDefaults mode to the boolean flag expected by KvsBuilder. + * + * Returns true if the mode is Required, false if Optional or Ignored, or + * std::nullopt if no mode was provided (omit the flag from the builder call). + * + * @param mode Optional KvsDefaults value to convert. + * @return Converted boolean flag or nullopt. + */ +inline std::optional to_need_flag(const std::optional& mode) { + if (!mode.has_value()) { + return std::nullopt; + } + return *mode == KvsDefaults::Required; +} + +/** + * @brief Convert an optional KvsLoad mode to the boolean flag expected by KvsBuilder. + * + * Returns true if the mode is Required, false if Optional or Ignored, or + * std::nullopt if no mode was provided (omit the flag from the builder call). + * + * @param mode Optional KvsLoad value to convert. + * @return Converted boolean flag or nullopt. + */ +inline std::optional to_need_flag(const std::optional& mode) { + if (!mode.has_value()) { + return std::nullopt; + } + return *mode == KvsLoad::Required; +} + +/** + * @brief Build a Kvs instance from KvsParameters, applying all optional fields. + * + * Applies instance_id, defaults flag, kvs_load flag, and working directory from + * the provided KvsParameters to a KvsBuilder and returns the constructed Kvs. + * + * @param params Parsed KVS parameters from the test input JSON. + * @return Constructed Kvs instance. + * @throws std::runtime_error if the build fails. + */ +inline score::mw::per::kvs::Kvs create_kvs(const KvsParameters& params) { + score::mw::per::kvs::KvsBuilder builder{ + score::mw::per::kvs::InstanceId{params.instance_id.value}}; + + if (const auto defaults = to_need_flag(params.defaults)) { + builder = builder.need_defaults_flag(*defaults); + } + if (const auto kvs_load = to_need_flag(params.kvs_load)) { + builder = builder.need_kvs_flag(*kvs_load); + } + if (params.dir.has_value()) { + builder = builder.dir(std::string(*params.dir)); + } + + auto build_result = builder.build(); + if (!build_result) { + throw std::runtime_error(std::string(build_result.error().Message())); + } + + return std::move(build_result.value()); +} + +} // namespace kvs_build_helpers + +#endif // INTERNALS_PERSISTENCY_KVS_BUILD_HELPERS_H_ diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp index 8bbc87ba744..8d51bc0a651 100644 --- a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp @@ -15,6 +15,7 @@ #include +#include #include #include #include @@ -56,6 +57,40 @@ double maybe_snap_noisy_decimal(double value) { return value; } +/** + * @brief Replace integer-encoded boolean values with JSON boolean literals. + * + * The C++ KVS library stores true/false as numeric 1/0 in the snapshot JSON. + * This function rewrites every occurrence of {"t":"bool","v":1} → {"t":"bool","v":true} + * and {"t":"bool","v":0} → {"t":"bool","v":false} so that Python json.loads() + * returns Python bool values instead of ints. + * + * @param json Input JSON string. + * @return JSON string with boolean values normalised to true/false literals. + */ +std::string canonicalize_bool_literals(const std::string& json) { + static const std::regex bool_value_pattern( + R"("t"\s*:\s*"bool"\s*,\s*"v"\s*:\s*(0|1)\b)"); + + std::string result; + result.reserve(json.size()); + + std::size_t cursor = 0; + for (std::sregex_iterator it(json.begin(), json.end(), bool_value_pattern), end; it != end; + ++it) { + const std::smatch& match = *it; + const auto group_pos = static_cast(match.position(1)); + const auto group_len = static_cast(match.length(1)); + + result.append(json, cursor, group_pos - cursor); + result += (match.str(1) == "1") ? "true" : "false"; + cursor = group_pos + group_len; + } + + result.append(json, cursor, std::string::npos); + return result; +} + std::string canonicalize_f64_literals(const std::string& json) { static const std::regex f64_value_pattern( R"("t"\s*:\s*"f64"\s*,\s*"v"\s*:\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?))"); @@ -87,6 +122,25 @@ std::string canonicalize_f64_literals(const std::string& json) { return result; } +/** + * @brief Compute the Adler-32 checksum of a byte string. + * + * Matches Python's ``zlib.adler32(data).to_bytes(4, byteorder='big')``. + * + * @param data Input byte string. + * @return 32-bit Adler-32 checksum. + */ +uint32_t adler32_hash(const std::string& data) { + static constexpr uint32_t kMod = 65521U; + uint32_t a = 1U; + uint32_t b = 0U; + for (const unsigned char byte : data) { + a = (a + static_cast(byte)) % kMod; + b = (b + a) % kMod; + } + return (b << 16U) | a; +} + std::optional snapshot_path(const KvsParameters& params) { if (!params.dir.has_value()) { return std::nullopt; @@ -180,7 +234,8 @@ bool KvsInstance::normalize_snapshot_file_to_rust_envelope(const KvsParameters& std::ostringstream buffer; buffer << in.rdbuf(); const std::string content = trim(buffer.str()); - const std::string canonical_content = canonicalize_f64_literals(content); + const std::string bool_canonical = canonicalize_bool_literals(content); + const std::string canonical_content = canonicalize_f64_literals(bool_canonical); std::string final_content; if (canonical_content.rfind("{\"t\":\"obj\",\"v\":", 0) == 0) { @@ -196,7 +251,33 @@ bool KvsInstance::normalize_snapshot_file_to_rust_envelope(const KvsParameters& } out << final_content; - return static_cast(out); + if (!out) { + return false; + } + out.close(); + + // Recalculate and rewrite the companion hash file so the snapshot stays + // valid after JSON modification. Hash = Adler-32 of the written JSON + // bytes stored as 4 big-endian bytes, matching Python's + // zlib.adler32(data).to_bytes(4, byteorder='big'). + const std::string hash_path_str = + (*path_opt).substr(0, (*path_opt).size() - 5U) + ".hash"; + const uint32_t checksum = adler32_hash(final_content); + const std::array hash_bytes = { + static_cast((checksum >> 24U) & 0xFFU), + static_cast((checksum >> 16U) & 0xFFU), + static_cast((checksum >> 8U) & 0xFFU), + static_cast( checksum & 0xFFU), + }; + std::ofstream hash_out(hash_path_str, std::ios::binary | std::ios::trunc); + if (!hash_out.is_open()) { + std::cerr << "Cannot normalize snapshot: failed to write hash " + << hash_path_str << std::endl; + return false; + } + hash_out.write(reinterpret_cast(hash_bytes.data()), + static_cast(hash_bytes.size())); + return static_cast(hash_out); } bool KvsInstance::set_value(const std::string& key, double value) { @@ -228,6 +309,34 @@ std::optional KvsInstance::get_value(const std::string& key) { } } +std::optional KvsInstance::get_value_f64(const std::string& key) { + auto result = kvs_.get_value(key); + if (!result) { + return std::nullopt; + } + + const auto& stored = result.value(); + if (stored.getType() != score::mw::per::kvs::KvsValue::Type::f64) { + return std::nullopt; + } + return std::get(stored.getValue()); +} + +bool KvsInstance::remove_key(const std::string& key) { + auto result = kvs_.remove_key(key); + return static_cast(result); +} + +bool KvsInstance::reset() { + auto result = kvs_.reset(); + return static_cast(result); +} + +bool KvsInstance::reset_key(const std::string& key) { + auto result = kvs_.reset_key(key); + return static_cast(result); +} + bool KvsInstance::flush() { auto result = kvs_.flush(); return static_cast(result); diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h index f5452c94e0d..3ac7c34d125 100644 --- a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h @@ -16,7 +16,6 @@ #include "kvs_parameters.h" -#include #include #include #include @@ -30,11 +29,17 @@ class KvsInstance { // Wrap snapshot file into Rust-style top-level object envelope. static bool normalize_snapshot_file_to_rust_envelope(const KvsParameters& params); - // Set a value + // Set value bool set_value(const std::string& key, double value); - // Get a value + // Get value methods std::optional get_value(const std::string& key); + std::optional get_value_f64(const std::string& key); + + // Key management methods + bool remove_key(const std::string& key); + bool reset(); + bool reset_key(const std::string& key); // Flush to persistent storage bool flush(); diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp index f1e6e183766..e897b1514e4 100644 --- a/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp @@ -16,12 +16,26 @@ #include Scenario::Ptr make_multiple_kvs_per_app_scenario(); +Scenario::Ptr make_default_values_ignored_scenario(); +Scenario::Ptr make_reset_to_default_scenario(); +Scenario::Ptr make_utf8_defaults_scenario(); +Scenario::Ptr make_utf8_default_value_get_scenario(); +Scenario::Ptr make_multi_instance_isolation_scenario(); +ScenarioGroup::Ptr supported_datatypes_group(); +ScenarioGroup::Ptr default_values_group(); ScenarioGroup::Ptr persistency_scenario_group() { return std::make_shared( "persistency", - std::vector{make_multiple_kvs_per_app_scenario()}, - std::vector{}); + std::vector{ + make_multiple_kvs_per_app_scenario(), + make_default_values_ignored_scenario(), + make_reset_to_default_scenario(), + make_utf8_defaults_scenario(), + make_utf8_default_value_get_scenario(), + make_multi_instance_isolation_scenario(), + }, + std::vector{supported_datatypes_group(), default_values_group()}); } ScenarioGroup::Ptr root_scenario_group() { diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/default_values.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/default_values.cpp new file mode 100644 index 00000000000..b3909864b05 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/default_values.cpp @@ -0,0 +1,258 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_build_helpers.h" +#include "../../internals/persistency/kvs_instance.h" + +#include + +#include +#include +#include + +using namespace score::mw::per::kvs; +using kvs_build_helpers::create_kvs; + +namespace { + +/// Set a value and flush. Python reads the snapshot bytes and the hash file, +/// verifying adler32(snapshot) matches the hash written by KVS. +/// NOTE: normalize is NOT called here so the hash file remains consistent +/// with the snapshot bytes (both are in the C++ KVS native format). +class Checksum final : public Scenario { +public: + std::string name() const final { return "checksum"; } + + void run(const std::string& input) const final { + auto params{KvsParameters::from_json_section(input, "kvs_parameters_1")}; + auto kvs{create_kvs(params)}; + + auto set_result{kvs.set_value("checksum_test_key", KvsValue{1.0})}; + if (!set_result) { + throw std::runtime_error{"Failed to set value"}; + } + + auto flush_result{kvs.flush()}; + if (!flush_result) { + throw std::runtime_error{"Failed to flush"}; + } + // Do NOT normalize: the hash file must match the snapshot bytes. + } +}; + +/// Open KVS with three default keys, override ONLY the middle key, flush. +/// Python verifies the snapshot contains exactly the one overridden key +/// while the other two keys are absent (never explicitly written). +/// Combines default_values + default_value_file + store_data requirements. +class PartialOverride final : public Scenario { +public: + std::string name() const final { return "partial_override"; } + + void run(const std::string& input) const final { + auto params{KvsParameters::from_json_section(input, "kvs_parameters_1")}; + + auto kvs_opt{KvsInstance::create(params)}; + if (!kvs_opt) { + throw std::runtime_error{"Failed to create KVS instance for PartialOverride"}; + } + auto kvs{*kvs_opt}; + + // Override only the middle key; partial_key_0 and partial_key_2 stay as defaults. + if (!kvs->set_value("partial_key_1", 999.0)) { + throw std::runtime_error{"Failed to set value"}; + } + + if (!kvs->flush()) { + throw std::runtime_error{"Failed to flush"}; + } + + // Log default values for key_0 and key_2 so Python can assert they are accessible. + auto val0{kvs->get_value_f64("partial_key_0")}; + if (!val0.has_value()) { + throw std::runtime_error{"Failed to read default value for 'partial_key_0'"}; + } + auto val2{kvs->get_value_f64("partial_key_2")}; + if (!val2.has_value()) { + throw std::runtime_error{"Failed to read default value for 'partial_key_2'"}; + } + kvs_build_helpers::log_info( + "\"key\":\"partial_key_0\",\"value\":" + kvs_build_helpers::format_double_python(val0.value()), + "cpp_test_scenarios::scenarios::persistency::default_values"); + kvs_build_helpers::log_info( + "\"key\":\"partial_key_2\",\"value\":" + kvs_build_helpers::format_double_python(val2.value()), + "cpp_test_scenarios::scenarios::persistency::default_values"); + + KvsInstance::normalize_snapshot_file_to_rust_envelope(params); + } +}; + +/// Open KVS with a defaults file containing one key. Call get_value on that key +/// without ever calling set_value, write the retrieved value to a probe key, flush. +/// Python verifies the probe key equals the expected default, confirming that +/// feat_req__persistency__default_value_get is satisfied. +class GetDefaultValue final : public Scenario { +public: + std::string name() const final { return "get_default_value"; } + + void run(const std::string& input) const final { + auto params{KvsParameters::from_json_section(input, "kvs_parameters_1")}; + + auto kvs_opt{KvsInstance::create(params)}; + if (!kvs_opt) { + throw std::runtime_error{"Failed to create KVS instance"}; + } + auto kvs{*kvs_opt}; + + // Read the default — this key has a default value but was never explicitly set. + auto default_result{kvs->get_value_f64("default_probe_key")}; + if (!default_result.has_value()) { + throw std::runtime_error{"Failed to read default value for 'default_probe_key'"}; + } + + // Persist the retrieved value to a probe key so Python can verify it. + if (!kvs->set_value("result_key", default_result.value())) { + throw std::runtime_error{"Failed to set result_key"}; + } + if (!kvs->flush()) { + throw std::runtime_error{"Failed to flush"}; + } + + KvsInstance::normalize_snapshot_file_to_rust_envelope(params); + } +}; + +/// Override all six keys, then call reset_key on even-indexed keys (0, 2, 4), +/// flush. Python verifies even-indexed keys are absent from the snapshot +/// (reverted to in-memory defaults) while odd-indexed keys (1, 3, 5) remain +/// with override values — combining feat_req__persistency__reset_to_default, +/// feat_req__persistency__default_values, feat_req__persistency__default_value_file, +/// and feat_req__persistency__store_data in one observable storage outcome. +class SelectiveReset final : public Scenario { +public: + std::string name() const final { return "selective_reset"; } + + void run(const std::string& input) const final { + const int num_keys{6}; + auto params{KvsParameters::from_json_section(input, "kvs_parameters_1")}; + auto kvs_opt{KvsInstance::create(params)}; + if (!kvs_opt) { + throw std::runtime_error{"Failed to create KVS instance"}; + } + auto kvs{*kvs_opt}; + + std::vector keys; + for (int i{0}; i < num_keys; ++i) { + std::string key{"sel_key_" + std::to_string(i)}; + if (!kvs->set_value(key, 100.0 * (i + 1))) { + throw std::runtime_error{"Failed to set value"}; + } + keys.push_back(key); + } + + if (!kvs->flush()) { + throw std::runtime_error{"Failed to flush after set"}; + } + + // Reset even-indexed keys (0, 2, 4); odd-indexed keep their overrides. + for (int i{0}; i < num_keys; i += 2) { + if (!kvs->reset_key(keys[i])) { + throw std::runtime_error{"Failed to reset key: " + keys[i]}; + } + } + + if (!kvs->flush()) { + throw std::runtime_error{"Failed to flush after reset"}; + } + + // Log default for sel_key_0 after reset_key — confirms key returns to its default value. + auto default_val{kvs->get_value_f64(keys[0])}; + if (!default_val.has_value()) { + throw std::runtime_error{"Failed to read default after reset for sel_key_0"}; + } + kvs_build_helpers::log_info( + "\"key\":\"sel_key_0\",\"value\":" + kvs_build_helpers::format_double_python(default_val.value()) + + ",\"source\":\"default_after_reset\"", + "cpp_test_scenarios::scenarios::persistency::default_values"); + KvsInstance::normalize_snapshot_file_to_rust_envelope(params); + } +}; + +/// Write four initial keys and flush, then call reset() to clear all keys, then write +/// two new keys and flush again. Python verifies all four initial keys are absent and +/// both new keys are present with the correct values — proving reset() cleared the +/// entire storage and subsequent writes persist correctly. +/// This is the only FIT scenario that exercises the "all keys" variant of +/// feat_req__persistency__reset_to_default (as opposed to reset_key which is covered +/// by SelectiveReset). Combines with feat_req__persistency__default_values, +/// feat_req__persistency__default_value_file, and feat_req__persistency__store_data. +class FullReset final : public Scenario { +public: + std::string name() const final { return "full_reset"; } + + void run(const std::string& input) const final { + auto params{KvsParameters::from_json_section(input, "kvs_parameters_1")}; + auto kvs_opt{KvsInstance::create(params)}; + if (!kvs_opt) { + throw std::runtime_error{"Failed to create KVS instance"}; + } + auto kvs{*kvs_opt}; + + // Phase 1: write four initial keys and flush. + for (int i{0}; i < 4; ++i) { + std::string key{"fr_key_" + std::to_string(i)}; + if (!kvs->set_value(key, 100.0 * (i + 1))) { + throw std::runtime_error{"Failed to set initial value for " + key}; + } + } + if (!kvs->flush()) { + throw std::runtime_error{"Failed to flush after initial set"}; + } + + // Phase 2: reset ALL keys. + if (!kvs->reset()) { + throw std::runtime_error{"Failed to reset all keys"}; + } + + // Log default for fr_key_0 after reset — confirms key returns to its default value. + auto default_val{kvs->get_value_f64("fr_key_0")}; + if (!default_val.has_value()) { + throw std::runtime_error{"Failed to read default after reset for fr_key_0"}; + } + kvs_build_helpers::log_info( + "\"key\":\"fr_key_0\",\"value\":" + kvs_build_helpers::format_double_python(default_val.value()) + + ",\"source\":\"default_after_reset\"", + "cpp_test_scenarios::scenarios::persistency::default_values"); + + if (!kvs->set_value("fr_new_0", 10.0)) { + throw std::runtime_error{"Failed to set fr_new_0"}; + } + if (!kvs->set_value("fr_new_1", 20.0)) { + throw std::runtime_error{"Failed to set fr_new_1"}; + } + if (!kvs->flush()) { + throw std::runtime_error{"Failed to flush after reset"}; + } + KvsInstance::normalize_snapshot_file_to_rust_envelope(params); + } +}; + +} // namespace + +ScenarioGroup::Ptr default_values_group() { + return ScenarioGroup::Ptr{new ScenarioGroupImpl{ + "default_values", + {std::make_shared(), std::make_shared(), + std::make_shared(), std::make_shared(), + std::make_shared()}, + {}}}; +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/default_values_ignored.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/default_values_ignored.cpp new file mode 100644 index 00000000000..49dae261e03 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/default_values_ignored.cpp @@ -0,0 +1,122 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_instance.h" + +#include "score/json/json_parser.h" + +#include + +#include +#include +#include + +namespace { + +struct TestInput { + std::string key; + double override_value; + + static TestInput from_json(const std::string& input); +}; + +class DefaultValuesIgnored : public Scenario { +public: + std::string name() const override; + void run(const std::string& input) const override; +}; + +TestInput TestInput::from_json(const std::string& input_json) { + const score::json::JsonParser parser; + const auto root_any_res = parser.FromBuffer(input_json); + if (!root_any_res.has_value()) { + throw std::invalid_argument("Failed to parse scenario input JSON"); + } + + const auto root_object_res = root_any_res.value().As(); + if (!root_object_res.has_value()) { + throw std::invalid_argument("Scenario input root must be an object"); + } + + const auto& root = root_object_res.value().get(); + const auto test_it = root.find("test"); + if (test_it == root.end()) { + throw std::invalid_argument("Missing test section"); + } + + const auto test_object_res = test_it->second.As(); + if (!test_object_res.has_value()) { + throw std::invalid_argument("test section must be an object"); + } + + const auto& test = test_object_res.value().get(); + TestInput parsed; + + if (const auto it = test.find("key"); it != test.end()) { + if (const auto val = it->second.As(); val.has_value()) { + parsed.key = std::string(val.value()); + } + } + + if (const auto it = test.find("override_value"); it != test.end()) { + if (const auto val = it->second.As(); val.has_value()) { + parsed.override_value = val.value(); + } + } + + return parsed; +} + +} // anonymous namespace + +std::string DefaultValuesIgnored::name() const { + return "default_values_ignored"; +} + +void DefaultValuesIgnored::run(const std::string& input) const { + // Parse parameters + KvsParameters params = KvsParameters::from_json_section(input, "kvs_parameters_1"); + TestInput test_input = TestInput::from_json(input); + + // Create KVS with Ignored mode + auto kvs_opt = KvsInstance::create(params); + if (!kvs_opt) { + throw std::runtime_error("Failed to create KVS instance"); + } + auto kvs = *kvs_opt; + + // NOTE: The C++ KvsBuilder API maps both KvsDefaults::Ignored and + // KvsDefaults::Optional to need_defaults_flag(false). When a defaults + // file is present on disk, the KVS will therefore still load it in this + // mode. The meaningful assertion for C++ is that explicitly set values + // ARE persisted — which is verified by the Python test reading the snapshot. + + // Set explicit value and flush to storage. Python reads the snapshot + // and verifies the explicitly set value is persisted. + if (!kvs->set_value(test_input.key, test_input.override_value)) { + throw std::runtime_error("Failed to set value"); + } + + if (!kvs->flush()) { + throw std::runtime_error("Failed to flush KVS"); + } + + // Normalize snapshot file for Python assertion + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params)) { + std::cerr << "Warning: Failed to normalize snapshot file" << std::endl; + } +} + +Scenario::Ptr make_default_values_ignored_scenario() { + return std::make_shared(); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/multi_instance_isolation.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/multi_instance_isolation.cpp new file mode 100644 index 00000000000..071d796ba6c --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/multi_instance_isolation.cpp @@ -0,0 +1,131 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_build_helpers.h" +#include "../../internals/persistency/kvs_instance.h" + +#include + +#include +#include + +namespace { + +/// Write key_a to instance 1 and key_b to instance 2 within a shared directory. +/// +/// Python verifies snapshot isolation: the snapshot of instance 1 contains +/// key_a but not key_b, and the snapshot of instance 2 contains key_b but +/// not key_a. This proves that default values loaded for one KVS instance +/// do not leak into a second instance sharing the same working directory. +/// +/// Partially verifies feat_req__persistency__default_values and +/// feat_req__persistency__multiple_kvs. +class MultiInstanceIsolation final : public Scenario { +public: + /** + * @brief Return the scenario name used to identify this scenario in the runner. + * @return Scenario name string. + */ + std::string name() const final { return "multi_instance_isolation"; } + + /** + * @brief Execute the isolation scenario. + * + * Opens two KVS instances from the JSON input parameters, writes key_a + * only to instance 1 and key_b only to instance 2, flushes both, and + * normalises the snapshot files to the Rust envelope format for Python + * verification. + * + * @param input JSON string containing kvs_parameters_1 and kvs_parameters_2. + */ + void run(const std::string& input) const final { + KvsParameters params1 = KvsParameters::from_json_section(input, "kvs_parameters_1"); + KvsParameters params2 = KvsParameters::from_json_section(input, "kvs_parameters_2"); + + // Write key_a exclusively to instance 1. + auto kvs1_opt = KvsInstance::create(params1); + if (!kvs1_opt) { + throw std::runtime_error("Failed to create KVS instance 1"); + } + auto kvs1 = *kvs1_opt; + + // Log instance 1's own default (key_a) — proves default was loaded for instance 1. + auto val_a = kvs1->get_value_f64("key_a"); + if (!val_a.has_value()) { + throw std::runtime_error("Instance 1 should have key_a default but it is not accessible"); + } + kvs_build_helpers::log_info( + "\"instance\":\"1\",\"key\":\"key_a\",\"value\":" + kvs_build_helpers::format_double_python(val_a.value()) + + ",\"source\":\"default\"", + "cpp_test_scenarios::scenarios::persistency::multi_instance_isolation"); + + // Confirm key_b is NOT accessible from instance 1 (isolation check). + auto cross_a = kvs1->get_value_f64("key_b"); + if (cross_a.has_value()) { + throw std::runtime_error("Isolation broken: instance 1 can access key_b from instance 2 defaults"); + } + + if (!kvs1->set_value("key_a", 11.0)) { + throw std::runtime_error("Failed to set key_a on instance 1"); + } + if (!kvs1->flush()) { + throw std::runtime_error("Failed to flush instance 1"); + } + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params1)) { + throw std::runtime_error("Failed to normalize snapshot for instance 1"); + } + + // Write key_b exclusively to instance 2. + auto kvs2_opt = KvsInstance::create(params2); + if (!kvs2_opt) { + throw std::runtime_error("Failed to create KVS instance 2"); + } + auto kvs2 = *kvs2_opt; + + // Log instance 2's own default (key_b) — proves default was loaded for instance 2. + auto val_b = kvs2->get_value_f64("key_b"); + if (!val_b.has_value()) { + throw std::runtime_error("Instance 2 should have key_b default but it is not accessible"); + } + kvs_build_helpers::log_info( + "\"instance\":\"2\",\"key\":\"key_b\",\"value\":" + kvs_build_helpers::format_double_python(val_b.value()) + + ",\"source\":\"default\"", + "cpp_test_scenarios::scenarios::persistency::multi_instance_isolation"); + + // Confirm key_a is NOT accessible from instance 2 (isolation check). + auto cross_b = kvs2->get_value_f64("key_a"); + if (cross_b.has_value()) { + throw std::runtime_error("Isolation broken: instance 2 can access key_a from instance 1 defaults"); + } + + if (!kvs2->set_value("key_b", 22.0)) { + throw std::runtime_error("Failed to set key_b on instance 2"); + } + if (!kvs2->flush()) { + throw std::runtime_error("Failed to flush instance 2"); + } + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params2)) { + throw std::runtime_error("Failed to normalize snapshot for instance 2"); + } + } +}; + +} // namespace + +/** + * @brief Factory function for MultiInstanceIsolation scenario. + * @return Shared pointer to the constructed scenario. + */ +Scenario::Ptr make_multi_instance_isolation_scenario() { + return std::make_shared(); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/reset_to_default.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/reset_to_default.cpp new file mode 100644 index 00000000000..9b40efeac5f --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/reset_to_default.cpp @@ -0,0 +1,89 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_build_helpers.h" +#include "../../internals/persistency/kvs_instance.h" + +#include + +#include +#include +#include + +namespace { + +struct TestInput { + // Data matches the Python test configuration; hardcoded since it's symmetric. + const std::vector keys{"key1", "key2", "key3"}; + const std::vector override_values{111.0, 222.0, 333.0}; + const std::vector default_values{100.0, 200.0, 300.0}; +}; + +class ResetToDefault : public Scenario { +public: + std::string name() const override; + void run(const std::string& input) const override; +}; + +} // anonymous namespace + +std::string ResetToDefault::name() const { + return "reset_to_default"; +} + +void ResetToDefault::run(const std::string& input) const { + KvsParameters params = KvsParameters::from_json_section(input, "kvs_parameters_1"); + const TestInput test_input; + + auto kvs_opt = KvsInstance::create(params); + if (!kvs_opt) { + throw std::runtime_error("Failed to create KVS instance"); + } + auto kvs = *kvs_opt; + + // Override all keys with new values + for (size_t i = 0; i < test_input.keys.size(); ++i) { + if (!kvs->set_value(test_input.keys[i], test_input.override_values[i])) { + throw std::runtime_error("Failed to override value"); + } + } + + // Reset key2 (index 1) using remove_key — reverts to default in memory + const auto& key_to_reset = test_input.keys[1]; + if (!kvs->remove_key(key_to_reset)) { + throw std::runtime_error("Failed to remove key"); + } + + // Log the default value reported by KVS after reset so Python can assert it. + auto default_val = kvs->get_value_f64(key_to_reset); + if (!default_val.has_value()) { + throw std::runtime_error("Failed to read default value after reset for 'key2'"); + } + kvs_build_helpers::log_info( + "\"key\":\"key2\",\"value\":" + kvs_build_helpers::format_double_python(default_val.value()) + + ",\"source\":\"default_after_reset\"", + "cpp_test_scenarios::scenarios::persistency::reset_to_default"); + + // Flush to persist: key1 and key3 with overrides, key2 absent + if (!kvs->flush()) { + throw std::runtime_error("Failed to flush KVS"); + } + + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params)) { + std::cerr << "Warning: Failed to normalize snapshot file" << std::endl; + } +} + +Scenario::Ptr make_reset_to_default_scenario() { + return std::make_shared(); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/supported_datatypes.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/supported_datatypes.cpp new file mode 100644 index 00000000000..88fb2d841cf --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/supported_datatypes.cpp @@ -0,0 +1,116 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_build_helpers.h" +#include "../../internals/persistency/kvs_instance.h" + +#include + +#include +#include +#include +#include +#include + +using namespace score::mw::per::kvs; +using kvs_build_helpers::create_kvs; + +namespace { + +/// Write all nine value types under ASCII key names in a single flush. +/// Python reads the snapshot and verifies every key is present with the correct +/// type tag and value — proving that primitive and composite types coexist +/// without interference in one atomic storage outcome. +/// Combines feat_req__persistency__support_datatype_value, +/// feat_req__persistency__support_datatype_keys, and +/// feat_req__persistency__store_data. +class AllValueTypes : public Scenario { +public: + std::string name() const final { return "all_value_types"; } + + void run(const std::string& input) const final { + KvsParameters params = KvsParameters::from_json_section(input, "kvs_parameters_1"); + Kvs kvs = create_kvs(params); + + std::unordered_map nested_obj = {{"sub-number", KvsValue(789.0)}}; + std::vector arr = { + KvsValue(321.5), KvsValue(false), KvsValue("hello"), + KvsValue(nullptr), KvsValue(std::vector{}), KvsValue(nested_obj), + }; + + auto check = [](auto result, const std::string& key) { + if (!result) { + throw std::runtime_error("Failed to set value for key: " + key); + } + }; + + check(kvs.set_value("i32_key", KvsValue(static_cast(-321))), "i32_key"); + check(kvs.set_value("u32_key", KvsValue(static_cast(1234))), "u32_key"); + check(kvs.set_value("i64_key", KvsValue(static_cast(-123456789))), "i64_key"); + check(kvs.set_value("u64_key", KvsValue(static_cast(123456789))), "u64_key"); + check(kvs.set_value("f64_key", KvsValue(-5432.1)), "f64_key"); + check(kvs.set_value("bool_key", KvsValue(true)), "bool_key"); + check(kvs.set_value("str_key", KvsValue("example")), "str_key"); + check(kvs.set_value("arr_key", KvsValue(arr)), "arr_key"); + check(kvs.set_value("obj_key", KvsValue(nested_obj)), "obj_key"); + + if (!kvs.flush()) { + throw std::runtime_error("Failed to flush"); + } + KvsInstance::normalize_snapshot_file_to_rust_envelope(params); + } +}; + +/// Write five values with mixed types under both ASCII and UTF-8 key names, +/// then flush and normalize. Python reads the single snapshot and verifies +/// every key is present with the correct type tag and value. +class AllTypesUtf8 : public Scenario { +public: + std::string name() const final { + return "all_types_utf8"; + } + + void run(const std::string& input) const final { + KvsParameters params = KvsParameters::from_json_section(input, "kvs_parameters_1"); + Kvs kvs = create_kvs(params); + + auto check = [](auto result, const std::string& key) { + if (!result) { + throw std::runtime_error("Failed to set value for key: " + key); + } + }; + + check(kvs.set_value("ascii_i32", KvsValue(static_cast(-321))), "ascii_i32"); + check(kvs.set_value(u8"emoji_f64 🎯", KvsValue(3.14)), u8"emoji_f64 🎯"); + check(kvs.set_value(u8"greek_bool αβγ", KvsValue(true)), u8"greek_bool αβγ"); + check(kvs.set_value("ascii_str", KvsValue("hello")), "ascii_str"); + check(kvs.set_value("ascii_null", KvsValue(nullptr)), "ascii_null"); + + if (!kvs.flush()) { + throw std::runtime_error("Failed to flush"); + } + + KvsInstance::normalize_snapshot_file_to_rust_envelope(params); + } +}; + +} // namespace + +ScenarioGroup::Ptr supported_datatypes_group() { + std::vector scenarios = { + std::make_shared(), + std::make_shared(), + }; + return std::make_shared( + "supported_datatypes", scenarios, std::vector{}); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/utf8_defaults.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/utf8_defaults.cpp new file mode 100644 index 00000000000..b1b15f7a201 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/utf8_defaults.cpp @@ -0,0 +1,125 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_build_helpers.h" +#include "../../internals/persistency/kvs_instance.h" + +#include + +#include +#include + +namespace { + +/// Open a KVS whose defaults file was created with UTF-8 encoded key names. +/// Override only the emoji key and flush. Python verifies: +/// - the emoji key appears in the snapshot with the override value +/// - the ASCII and Greek keys are absent (never explicitly written) +/// +/// Combines feat_req__persistency__support_datatype_keys, +/// feat_req__persistency__default_values, and +/// feat_req__persistency__default_value_file. +class Utf8Defaults : public Scenario { +public: + std::string name() const override { + return "utf8_defaults"; + } + + void run(const std::string& input) const override { + KvsParameters params = KvsParameters::from_json_section(input, "kvs_parameters_1"); + + auto kvs_opt = KvsInstance::create(params); + if (!kvs_opt) { + throw std::runtime_error("Failed to create KVS instance"); + } + auto kvs = *kvs_opt; + + // Override only the emoji key; ascii and greek keys remain as defaults. + if (!kvs->set_value(u8"utf8_emoji 🔑", 777.0)) { + throw std::runtime_error("Failed to set value"); + } + + if (!kvs->flush()) { + throw std::runtime_error("Failed to flush KVS"); + } + + // Log default values for ascii and greek keys so Python can assert they are accessible. + auto val_ascii = kvs->get_value_f64("utf8_ascii_key"); + if (!val_ascii.has_value()) { + throw std::runtime_error("Failed to read default value for 'utf8_ascii_key'"); + } + auto val_greek = kvs->get_value_f64(u8"utf8_greek κλμ"); + if (!val_greek.has_value()) { + throw std::runtime_error(u8"Failed to read default value for 'utf8_greek κλμ'"); + } + kvs_build_helpers::log_info( + "\"key\":\"utf8_ascii_key\",\"value\":" + kvs_build_helpers::format_double_python(val_ascii.value()), + "cpp_test_scenarios::scenarios::persistency::utf8_defaults"); + kvs_build_helpers::log_info( + u8"\"key\":\"utf8_greek \u03ba\u03bb\u03bc\",\"value\":" + kvs_build_helpers::format_double_python(val_greek.value()), + "cpp_test_scenarios::scenarios::persistency::utf8_defaults"); + + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params)) { + std::cerr << "Warning: Failed to normalize snapshot file" << std::endl; + } + } +}; + +/// Read a default value whose key is a UTF-8 emoji string without ever calling +/// set_value on it. Write the retrieved value to an ASCII result key and flush. +/// Python verifies the result key equals the expected default, combining +/// feat_req__persistency__default_value_get with +/// feat_req__persistency__support_datatype_keys in one observable storage outcome. +class Utf8DefaultValueGet : public Scenario { +public: + std::string name() const override { + return "utf8_default_value_get"; + } + + void run(const std::string& input) const override { + KvsParameters params = KvsParameters::from_json_section(input, "kvs_parameters_1"); + + auto kvs_opt = KvsInstance::create(params); + if (!kvs_opt) { + throw std::runtime_error("Failed to create KVS instance"); + } + auto kvs = *kvs_opt; + + // Read the default via a UTF-8 emoji key — never explicitly set. + auto default_result = kvs->get_value_f64(u8"probe 🔍"); + if (!default_result.has_value()) { + throw std::runtime_error(u8"Failed to read default value for 'probe 🔍'"); + } + + // Persist to an ASCII result key so Python can verify without UTF-8 key lookup. + if (!kvs->set_value("result_key", default_result.value())) { + throw std::runtime_error("Failed to set result_key"); + } + if (!kvs->flush()) { + throw std::runtime_error("Failed to flush KVS"); + } + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params)) { + std::cerr << "Warning: Failed to normalize snapshot file" << std::endl; + } + } +}; + +} // anonymous namespace + +Scenario::Ptr make_utf8_defaults_scenario() { + return std::make_shared(); +} + +Scenario::Ptr make_utf8_default_value_get_scenario() { + return std::make_shared(); +} diff --git a/feature_integration_tests/test_scenarios/rust/BUILD b/feature_integration_tests/test_scenarios/rust/BUILD index 6ef6c741be3..06e43f46726 100644 --- a/feature_integration_tests/test_scenarios/rust/BUILD +++ b/feature_integration_tests/test_scenarios/rust/BUILD @@ -19,7 +19,7 @@ rust_binary( tags = [ "manual", ], - visibility = ["//feature_integration_tests/test_cases:__pkg__"], + visibility = ["//visibility:public"], deps = [ "@score_crates//:serde", "@score_crates//:serde_json", diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/default_values.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/default_values.rs new file mode 100644 index 00000000000..e51203aedcb --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/default_values.rs @@ -0,0 +1,197 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* +use crate::internals::persistency::{kvs_instance::kvs_instance, kvs_parameters::KvsParameters}; +use rust_kvs::prelude::*; +use serde_json::Value; +use test_scenarios_rust::scenario::{Scenario, ScenarioGroup, ScenarioGroupImpl}; +use tracing::info; + +fn parse_params(input: &str) -> Result { + let v: Value = serde_json::from_str(input).map_err(|e| e.to_string())?; + KvsParameters::from_value(&v["kvs_parameters_1"]).map_err(|e| e.to_string()) +} + +/// Set a single value and flush. Python reads the snapshot bytes and the hash +/// file, verifying adler32(snapshot) equals the hash written by KVS — exercising +/// feat_req__persistency__integrity_check alongside feat_req__persistency__store_data. +struct Checksum; + +impl Scenario for Checksum { + fn name(&self) -> &str { + "checksum" + } + + fn run(&self, input: &str) -> Result<(), String> { + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + kvs.set_value("checksum_test_key", 1.0).map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + Ok(()) + } +} + +/// Open KVS with three default keys, override ONLY the middle key, flush. +/// Python verifies the snapshot contains exactly the one overridden key +/// (others were never explicitly written so they remain absent from storage). +/// Combines feat_req__persistency__default_values, +/// feat_req__persistency__default_value_file, and +/// feat_req__persistency__store_data in one observable storage outcome. +struct PartialOverride; + +impl Scenario for PartialOverride { + fn name(&self) -> &str { + "partial_override" + } + + fn run(&self, input: &str) -> Result<(), String> { + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + // Override only the middle key; key_0 and key_2 are intentionally left at defaults. + kvs.set_value("partial_key_1", 999.0_f64) + .map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + // Log default values for key_0 and key_2 so Python can assert they are accessible. + let val0: f64 = kvs + .get_value_as("partial_key_0") + .map_err(|e| format!("Failed to read default partial_key_0: {e:?}"))?; + let val2: f64 = kvs + .get_value_as("partial_key_2") + .map_err(|e| format!("Failed to read default partial_key_2: {e:?}"))?; + info!(key = "partial_key_0", value = val0, source = "default"); + info!(key = "partial_key_2", value = val2, source = "default"); + Ok(()) + } +} + +/// Open KVS with a defaults file containing one key. Call get_value on that key +/// without ever calling set_value, write the retrieved value to a probe key, flush. +/// Python verifies the probe key equals the expected default, confirming that +/// feat_req__persistency__default_value_get is satisfied. +struct GetDefaultValue; + +impl Scenario for GetDefaultValue { + fn name(&self) -> &str { + "get_default_value" + } + + fn run(&self, input: &str) -> Result<(), String> { + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + // Read the default — this key has a default value but was never explicitly set. + let default_val: f64 = kvs + .get_value_as("default_probe_key") + .map_err(|e| format!("Failed to read default value: {e:?}"))?; + // Persist the retrieved value to a probe key so Python can verify it. + kvs.set_value("result_key", default_val).map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + Ok(()) + } +} + +/// Override all six keys, then call reset_key on even-indexed keys (0, 2, 4), +/// flush. Python verifies even-indexed keys are absent from the snapshot +/// (reverted to in-memory defaults) while odd-indexed keys (1, 3, 5) remain +/// with their override values — combining feat_req__persistency__reset_to_default, +/// feat_req__persistency__default_values, feat_req__persistency__default_value_file, +/// and feat_req__persistency__store_data in one observable storage outcome. +struct SelectiveReset; + +impl Scenario for SelectiveReset { + fn name(&self) -> &str { + "selective_reset" + } + + fn run(&self, input: &str) -> Result<(), String> { + let num_keys = 6usize; + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + + let mut keys = Vec::new(); + for i in 0..num_keys { + let key = format!("sel_key_{i}"); + kvs.set_value(key.clone(), 100.0 * (i + 1) as f64) + .map_err(|e| format!("{e:?}"))?; + keys.push(key); + } + kvs.flush().map_err(|e| format!("{e:?}"))?; + + // Reset even-indexed keys (0, 2, 4); odd-indexed keep their overrides. + for i in (0..num_keys).step_by(2) { + kvs.reset_key(&keys[i]).map_err(|e| format!("{e:?}"))?; + } + kvs.flush().map_err(|e| format!("{e:?}"))?; + + // Log default for sel_key_0 after reset_key — confirms key returns to its default value. + let default_val: f64 = kvs + .get_value_as(&keys[0]) + .map_err(|e| format!("Failed to read default after reset for sel_key_0: {e:?}"))?; + info!(key = "sel_key_0", value = default_val, source = "default_after_reset"); + Ok(()) + } +} + +/// Write four initial keys and flush, then call reset() to clear all keys, then write +/// two new keys and flush again. Python verifies all four initial keys are absent and +/// both new keys are present with the correct values — proving reset() cleared the +/// entire storage and subsequent writes persist correctly. +/// This is the only FIT scenario that exercises the "all keys" variant of +/// feat_req__persistency__reset_to_default (as opposed to reset_key which is covered +/// by SelectiveReset). Combines with feat_req__persistency__default_values, +/// feat_req__persistency__default_value_file, and feat_req__persistency__store_data. +struct FullReset; + +impl Scenario for FullReset { + fn name(&self) -> &str { + "full_reset" + } + + fn run(&self, input: &str) -> Result<(), String> { + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + + // Phase 1: write four initial keys and flush. + for i in 0..4usize { + kvs.set_value(format!("fr_key_{i}"), 100.0 * (i + 1) as f64) + .map_err(|e| format!("{e:?}"))?; + } + kvs.flush().map_err(|e| format!("{e:?}"))?; + + // Phase 2: reset ALL keys, write two new keys, flush. + kvs.reset().map_err(|e| format!("{e:?}"))?; + + // Log default for fr_key_0 after reset — confirms key returns to its default value. + let default_val: f64 = kvs + .get_value_as("fr_key_0") + .map_err(|e| format!("Failed to read default after reset for fr_key_0: {e:?}"))?; + info!(key = "fr_key_0", value = default_val, source = "default_after_reset"); + + kvs.set_value("fr_new_0", 10.0_f64).map_err(|e| format!("{e:?}"))?; + kvs.set_value("fr_new_1", 20.0_f64).map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + Ok(()) + } +} + +pub fn default_values_group() -> Box { + Box::new(ScenarioGroupImpl::new( + "default_values", + vec![ + Box::new(Checksum), + Box::new(PartialOverride), + Box::new(GetDefaultValue), + Box::new(SelectiveReset), + Box::new(FullReset), + ], + vec![], + )) +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/default_values_ignored.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/default_values_ignored.rs new file mode 100644 index 00000000000..9882b12e8c2 --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/default_values_ignored.rs @@ -0,0 +1,62 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* +use crate::internals::persistency::{kvs_instance::kvs_instance, kvs_parameters::KvsParameters}; +use rust_kvs::prelude::KvsApi; +use serde::Deserialize; +use serde_json::Value; +use test_scenarios_rust::scenario::Scenario; + +#[derive(Deserialize, Debug)] +pub struct TestInput { + key: String, + override_value: f64, +} + +impl TestInput { + pub fn from_json(input: &str) -> Result { + let v: Value = serde_json::from_str(input).map_err(|e| e.to_string())?; + serde_json::from_value(v["test"].clone()).map_err(|e| e.to_string()) + } +} + +pub struct DefaultValuesIgnored; + +impl Scenario for DefaultValuesIgnored { + fn name(&self) -> &str { + "default_values_ignored" + } + + fn run(&self, input: &str) -> Result<(), String> { + // Parse parameters + let v: Value = serde_json::from_str(input).expect("Failed to parse input string"); + let params = KvsParameters::from_value(&v["kvs_parameters_1"]).expect("Failed to parse parameters"); + let test_input = TestInput::from_json(input).expect("Failed to parse test input"); + + // Create KVS with Ignored mode - defaults file exists but should not be loaded + let kvs = kvs_instance(params).expect("Failed to create KVS instance"); + + // In Ignored mode, getting a non-existent key should fail (no defaults loaded) + let result: Result = kvs.get_value_as(&test_input.key); + if result.is_ok() { + return Err("Expected get_value to fail with Ignored mode, but it succeeded".to_string()); + } + + // Set explicit value and flush to storage. Python reads the snapshot + // and verifies the explicitly set value is persisted. + kvs.set_value(&test_input.key, test_input.override_value) + .expect("Failed to set value"); + kvs.flush().expect("Failed to flush KVS"); + + Ok(()) + } +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/mod.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/mod.rs index 62353e6aede..7526fe20f66 100644 --- a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/mod.rs +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/mod.rs @@ -10,15 +10,35 @@ // // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* +mod default_values; +mod default_values_ignored; +mod multi_instance_isolation; mod multiple_kvs_per_app; +mod reset_to_default; +mod supported_datatypes; +mod utf8_defaults; +use default_values::default_values_group; +use default_values_ignored::DefaultValuesIgnored; +use multi_instance_isolation::MultiInstanceIsolation; use multiple_kvs_per_app::MultipleKvsPerApp; +use reset_to_default::ResetToDefault; +use supported_datatypes::supported_datatypes_group; use test_scenarios_rust::scenario::{ScenarioGroup, ScenarioGroupImpl}; +use utf8_defaults::Utf8DefaultValueGet; +use utf8_defaults::Utf8Defaults; pub fn persistency_group() -> Box { Box::new(ScenarioGroupImpl::new( "persistency", - vec![Box::new(MultipleKvsPerApp)], - vec![], + vec![ + Box::new(MultipleKvsPerApp), + Box::new(DefaultValuesIgnored), + Box::new(ResetToDefault), + Box::new(Utf8Defaults), + Box::new(Utf8DefaultValueGet), + Box::new(MultiInstanceIsolation), + ], + vec![supported_datatypes_group(), default_values_group()], )) } diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/multi_instance_isolation.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/multi_instance_isolation.rs new file mode 100644 index 00000000000..58039dbd63e --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/multi_instance_isolation.rs @@ -0,0 +1,82 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use crate::internals::persistency::{kvs_instance::kvs_instance, kvs_parameters::KvsParameters}; +use rust_kvs::prelude::KvsApi; +use serde_json::Value; +use test_scenarios_rust::scenario::Scenario; +use tracing::info; + +/// Write key_a to instance 1 and key_b to instance 2 within a shared directory. +/// +/// Python verifies snapshot isolation: the snapshot of instance 1 contains +/// key_a but not key_b, and the snapshot of instance 2 contains key_b but +/// not key_a. This proves that default values loaded for one KVS instance +/// do not leak into a second instance sharing the same working directory. +/// +/// Partially verifies feat_req__persistency__default_values and +/// feat_req__persistency__multiple_kvs. +pub struct MultiInstanceIsolation; + +impl Scenario for MultiInstanceIsolation { + fn name(&self) -> &str { + "multi_instance_isolation" + } + + fn run(&self, input: &str) -> Result<(), String> { + let v: Value = serde_json::from_str(input).map_err(|e| e.to_string())?; + + let params1 = KvsParameters::from_value(&v["kvs_parameters_1"]).map_err(|e| e.to_string())?; + let params2 = KvsParameters::from_value(&v["kvs_parameters_2"]).map_err(|e| e.to_string())?; + + // Write key_a exclusively to instance 1. + let kvs1 = kvs_instance(params1).map_err(|e| format!("{e:?}"))?; + + // Log instance 1's own default (key_a) — proves default was loaded. + let val_a: f64 = kvs1 + .get_value_as("key_a") + .map_err(|e| format!("Instance 1 should have key_a default: {e:?}"))?; + info!(instance = "1", key = "key_a", value = val_a, source = "default"); + + // Confirm key_b is NOT accessible from instance 1 (isolation check). + let cross_a: Result = kvs1.get_value_as("key_b"); + if cross_a.is_ok() { + return Err("Isolation broken: instance 1 can access key_b from instance 2 defaults".to_string()); + } + + kvs1.set_value("key_a", 11.0_f64) + .map_err(|e| format!("Failed to set key_a: {e:?}"))?; + kvs1.flush().map_err(|e| format!("Failed to flush instance 1: {e:?}"))?; + + // Write key_b exclusively to instance 2. + let kvs2 = kvs_instance(params2).map_err(|e| format!("{e:?}"))?; + + // Log instance 2's own default (key_b) — proves default was loaded. + let val_b: f64 = kvs2 + .get_value_as("key_b") + .map_err(|e| format!("Instance 2 should have key_b default: {e:?}"))?; + info!(instance = "2", key = "key_b", value = val_b, source = "default"); + + // Confirm key_a is NOT accessible from instance 2 (isolation check). + let cross_b: Result = kvs2.get_value_as("key_a"); + if cross_b.is_ok() { + return Err("Isolation broken: instance 2 can access key_a from instance 1 defaults".to_string()); + } + + kvs2.set_value("key_b", 22.0_f64) + .map_err(|e| format!("Failed to set key_b: {e:?}"))?; + kvs2.flush().map_err(|e| format!("Failed to flush instance 2: {e:?}"))?; + + Ok(()) + } +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/reset_to_default.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/reset_to_default.rs new file mode 100644 index 00000000000..3b4432344a1 --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/reset_to_default.rs @@ -0,0 +1,70 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* +use crate::internals::persistency::{kvs_instance::kvs_instance, kvs_parameters::KvsParameters}; +use rust_kvs::prelude::KvsApi; +use serde::Deserialize; +use serde_json::Value; +use test_scenarios_rust::scenario::Scenario; +use tracing::info; + +#[derive(Deserialize, Debug)] +pub struct TestInput { + keys: Vec, + override_values: Vec, + default_values: Vec, +} + +impl TestInput { + pub fn from_json(input: &str) -> Result { + let v: Value = serde_json::from_str(input).map_err(|e| e.to_string())?; + serde_json::from_value(v["test"].clone()).map_err(|e| e.to_string()) + } +} + +pub struct ResetToDefault; + +impl Scenario for ResetToDefault { + fn name(&self) -> &str { + "reset_to_default" + } + + fn run(&self, input: &str) -> Result<(), String> { + // Parse parameters + let v: Value = serde_json::from_str(input).expect("Failed to parse input string"); + let params = KvsParameters::from_value(&v["kvs_parameters_1"]).expect("Failed to parse parameters"); + let test_input = TestInput::from_json(input).expect("Failed to parse test input"); + + // Create KVS with Optional mode - defaults should be loaded + let kvs = kvs_instance(params).expect("Failed to create KVS instance"); + + // Override all keys with new values + for (i, key) in test_input.keys.iter().enumerate() { + kvs.set_value(key, test_input.override_values[i]) + .expect("Failed to override value"); + } + + // Reset key2 (index 1) using remove_key — reverts to default in memory + kvs.remove_key(&test_input.keys[1]).expect("Failed to remove key"); + + // Log the default value reported by KVS after reset so Python can assert it. + let default_val: f64 = kvs + .get_value_as(&test_input.keys[1]) + .expect("Failed to read default value after reset"); + info!(key = "key2", value = default_val, source = "default_after_reset"); + + // Flush to persist the state: key1 and key3 with overrides, key2 absent + kvs.flush().expect("Failed to flush KVS"); + + Ok(()) + } +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/supported_datatypes.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/supported_datatypes.rs new file mode 100644 index 00000000000..89dfea07f99 --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/supported_datatypes.rs @@ -0,0 +1,108 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* +use crate::internals::persistency::{kvs_instance::kvs_instance, kvs_parameters::KvsParameters}; +use rust_kvs::prelude::*; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use test_scenarios_rust::scenario::{Scenario, ScenarioGroup, ScenarioGroupImpl}; + +/// Write all nine value types under ASCII key names in a single flush. +/// Python reads the snapshot and verifies every key is present with the correct +/// type tag and value — proving that primitive and composite types coexist +/// without interference in one atomic storage outcome. +/// Combines feat_req__persistency__support_datatype_value, +/// feat_req__persistency__support_datatype_keys, and +/// feat_req__persistency__store_data. +struct AllValueTypes; + +impl Scenario for AllValueTypes { + fn name(&self) -> &str { + "all_value_types" + } + + fn run(&self, input: &str) -> Result<(), String> { + let v: JsonValue = serde_json::from_str(input).map_err(|e| e.to_string())?; + let params = KvsParameters::from_value(&v["kvs_parameters_1"]).map_err(|e| e.to_string())?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + + let nested_obj = HashMap::from([("sub-number".to_string(), KvsValue::from(789.0))]); + let array = vec![ + KvsValue::from(321.5), + KvsValue::from(false), + KvsValue::from("hello".to_string()), + KvsValue::from(()), + KvsValue::from(vec![]), + KvsValue::from(nested_obj.clone()), + ]; + + kvs.set_value("i32_key", KvsValue::I32(-321)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("u32_key", KvsValue::U32(1234)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("i64_key", KvsValue::I64(-123456789)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("u64_key", KvsValue::U64(123456789)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("f64_key", KvsValue::F64(-5432.1)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("bool_key", KvsValue::Boolean(true)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("str_key", KvsValue::String("example".to_string())) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("arr_key", KvsValue::Array(array)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("obj_key", KvsValue::Object(nested_obj)) + .map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + Ok(()) + } +} + +/// Write five values with mixed types under both ASCII and UTF-8 key names, +/// then flush. Python reads the single snapshot and verifies every key is +/// present with the correct type tag and value — combining key-encoding and +/// value-type requirements in one observable storage outcome. +struct AllTypesUtf8; + +impl Scenario for AllTypesUtf8 { + fn name(&self) -> &str { + "all_types_utf8" + } + + fn run(&self, input: &str) -> Result<(), String> { + let v: JsonValue = serde_json::from_str(input).map_err(|e| e.to_string())?; + let params = KvsParameters::from_value(&v["kvs_parameters_1"]).map_err(|e| e.to_string())?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + + kvs.set_value("ascii_i32", KvsValue::I32(-321)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("emoji_f64 🎯", KvsValue::F64(3.14)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("greek_bool αβγ", KvsValue::Boolean(true)) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("ascii_str", KvsValue::String("hello".to_string())) + .map_err(|e| format!("{e:?}"))?; + kvs.set_value("ascii_null", KvsValue::Null) + .map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + Ok(()) + } +} + +pub fn supported_datatypes_group() -> Box { + Box::new(ScenarioGroupImpl::new( + "supported_datatypes", + vec![Box::new(AllValueTypes), Box::new(AllTypesUtf8)], + vec![], + )) +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/utf8_defaults.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/utf8_defaults.rs new file mode 100644 index 00000000000..5aa12b5e0ff --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/persistency/utf8_defaults.rs @@ -0,0 +1,84 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* +use crate::internals::persistency::{kvs_instance::kvs_instance, kvs_parameters::KvsParameters}; +use rust_kvs::prelude::KvsApi; +use serde_json::Value; +use test_scenarios_rust::scenario::Scenario; +use tracing::info; + +fn parse_params(input: &str) -> Result { + let v: Value = serde_json::from_str(input).map_err(|e| e.to_string())?; + KvsParameters::from_value(&v["kvs_parameters_1"]).map_err(|e| e.to_string()) +} + +/// Open a KVS whose defaults file was created with UTF-8 encoded key names. +/// Override only the emoji key and flush. Python verifies: +/// - the emoji key appears in the snapshot with the override value +/// - the ASCII and Greek keys are absent from the snapshot (never explicitly written) +/// +/// Combines feat_req__persistency__support_datatype_keys, +/// feat_req__persistency__default_values, and +/// feat_req__persistency__default_value_file. +pub struct Utf8Defaults; + +impl Scenario for Utf8Defaults { + fn name(&self) -> &str { + "utf8_defaults" + } + + fn run(&self, input: &str) -> Result<(), String> { + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + + // Override only the emoji key; ASCII and Greek keys are intentionally left as defaults. + kvs.set_value("utf8_emoji 🔑", 777.0_f64) + .map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + // Log default values for ASCII and Greek keys so Python can assert accessibility. + let val_ascii: f64 = kvs + .get_value_as("utf8_ascii_key") + .map_err(|e| format!("Failed to read default utf8_ascii_key: {e:?}"))?; + let val_greek: f64 = kvs + .get_value_as("utf8_greek κλμ") + .map_err(|e| format!("Failed to read default utf8_greek κλμ: {e:?}"))?; + info!(key = "utf8_ascii_key", value = val_ascii, source = "default"); + info!(key = "utf8_greek κλμ", value = val_greek, source = "default"); + Ok(()) + } +} + +/// Read a default value whose key is a UTF-8 emoji string without ever calling +/// set_value on it. Write the retrieved value to an ASCII result key and flush. +/// Python verifies the result key equals the expected default, combining +/// feat_req__persistency__default_value_get with +/// feat_req__persistency__support_datatype_keys in one observable storage outcome. +pub struct Utf8DefaultValueGet; + +impl Scenario for Utf8DefaultValueGet { + fn name(&self) -> &str { + "utf8_default_value_get" + } + + fn run(&self, input: &str) -> Result<(), String> { + let params = parse_params(input)?; + let kvs = kvs_instance(params).map_err(|e| format!("{e:?}"))?; + // Read the default value via a UTF-8 emoji key — never explicitly set. + let default_val: f64 = kvs + .get_value_as("probe 🔍") + .map_err(|e| format!("Failed to read UTF-8 default value: {e:?}"))?; + // Persist to an ASCII result key so Python can verify without UTF-8 key lookup. + kvs.set_value("result_key", default_val).map_err(|e| format!("{e:?}"))?; + kvs.flush().map_err(|e| format!("{e:?}"))?; + Ok(()) + } +}