diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 6dfd32eb8..e0812a64b 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -259,13 +259,18 @@ Each model is validated for the required ``name`` field, for exactly one of ``cadDataFormat``, ``motionHint``, ``optimizeTrackingFor``, ``simplify`` and ``trackingMode`` fields being one of the values which the Model Target OpenAPI specification documents for it when the field is given, and for ``views`` -being a JSON array when it is given. +being a JSON array when it is given. The optional +``stateBasedConfigurationJsonString`` field must be a string containing a JSON +object with a ``states`` object. The ``realisticAppearance`` model field is validated in the same way for advanced datasets; the OpenAPI specification does not document it as a standard dataset model field, so standard dataset creation does not validate it. Each ``views`` entry is validated for being a JSON object, for the required ``guideViewPosition`` and ``name`` fields, and for those fields' types. +An optional ``states`` field must be an array of strings. Each named state must +be declared by the model's ``stateBasedConfigurationJsonString``. Omitting the +field makes the view available to every configured state. Each ``guideViewPosition`` object is validated for the required ``rotation`` and ``translation`` fields, for those fields being JSON arrays, and for the elements of those arrays being JSON numbers. @@ -274,6 +279,8 @@ The mock does not validate the contents of each model further, such as whether base64-encoded archives of the named ``cadDataFormat``, whether ``cadDataFormat`` is given alongside ``cadDataBlob``, the lengths of ``rotation`` and ``translation`` arrays, or ``targetSdk`` version numbers. +It also does not validate the state configuration beyond its top-level +``states`` object. For unknown Model Target datasets, the mock returns an error whose ``target`` is ``userId:mock``. Real Vuforia uses ``userId:`` where the numeric portion is per-account. @@ -282,10 +289,12 @@ Standard and advanced datasets are separate resources. A dataset created through the standard routes is not visible to the advanced routes, and the other way around: the mock returns the unknown-dataset error for status, download and delete requests made through the other dataset type's routes. Real Vuforia separates these by OAuth scope as well, which the mock does not model, so a client which lacks the advanced-dataset scope may see a different error. -Three Model Target Web API error paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``. +Some Model Target Web API paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``. Downloads of still-processing datasets are mock-only because exercising the path against real Vuforia would require creating a dataset on every test run; the mock drives the processing window deterministically. Advanced-dataset creation with more than 20 models is mock-only because the available test account lacks the advanced-dataset scope and real Vuforia rejects the request with a 403 before validating model counts. Cross-dataset-type access is mock-only for the same reason. +State-Based Model Target creation and validation are also mock-only because the +available test account lacks the State-Based Model Target scopes. Reco counts reports ------------------- diff --git a/newsfragments/model-target-state-fields.change b/newsfragments/model-target-state-fields.change new file mode 100644 index 000000000..4fb5b73bd --- /dev/null +++ b/newsfragments/model-target-state-fields.change @@ -0,0 +1 @@ +Accept State-Based Model Target configuration and validate per-view state selections against its declared states. diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py index e6c20cbd9..97602010d 100644 --- a/src/mock_vws/_model_target_web_api.py +++ b/src/mock_vws/_model_target_web_api.py @@ -461,7 +461,13 @@ def _model_field_details( return missing_details + cad_data_source_details string_fields = sorted( - {"cadDataBlob", "cadDataUrl", "name", *enum_field_values}, + { + "cadDataBlob", + "cadDataUrl", + "name", + "stateBasedConfigurationJsonString", + *enum_field_values, + }, ) string_details = [ { @@ -626,6 +632,129 @@ def _guide_view_position_details( ] +@beartype +def _configuration_states( + *, + model_index: int, + configuration_string: str, +) -> tuple[frozenset[str] | None, dict[str, str] | None]: + """Load the state names from a State-Based Model Target config.""" + try: + configuration: Any = json.loads(s=configuration_string) + except json.JSONDecodeError: + return None, { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/stateBasedConfigurationJsonString: " + "error.expected.validjson" + ), + } + if not _is_json_object(value=configuration): + return None, { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/stateBasedConfigurationJsonString/" + "states: error.expected.jsobject" + ), + } + configuration_states_value: object = configuration.get("states") + if not _is_json_object(value=configuration_states_value): + return None, { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/stateBasedConfigurationJsonString/" + "states: error.expected.jsobject" + ), + } + configuration_states: dict[str, Any] = configuration["states"] + state_names = frozenset(configuration_states) + return state_names, None + + +@beartype +def _state_based_details(*, models: list[Any]) -> list[dict[str, str]]: + """Return validation details for State-Based Model Targets.""" + state_fields = [ + (model_index, view_index, view["states"]) + for model_index, model in enumerate(iterable=models) + for view_index, view in enumerate(iterable=model.get("views", [])) + if "states" in view + ] + array_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/states: " + "error.expected.jsarray" + ), + } + for model_index, view_index, states in state_fields + if not isinstance(states, list) + ] + if array_details: + return array_details + + element_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/states" + f"({state_index}): error.expected.jsstring" + ), + } + for model_index, view_index, states in state_fields + for state_index, state in enumerate(iterable=states) + if not isinstance(state, str) + ] + if element_details: + return element_details + + details: list[dict[str, str]] = [] + configured_states: dict[int, frozenset[str]] = {} + for model_index, model in enumerate(iterable=models): + configuration_string = model.get("stateBasedConfigurationJsonString") + if not isinstance(configuration_string, str): + continue + state_names, detail = _configuration_states( + model_index=model_index, + configuration_string=configuration_string, + ) + if detail is not None: + details.append(detail) + if state_names is not None: + configured_states[model_index] = state_names + + if details: + return details + + for model_index, view_index, states in state_fields: + if model_index not in configured_states: + details.append( + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/" + "stateBasedConfigurationJsonString: element is " + "required when view states are given" + ), + }, + ) + continue + details.extend( + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/states" + f"({state_index}): error.expected.validenum" + ), + } + for state_index, state in enumerate(iterable=states) + if state not in configured_states[model_index] + ) + + return details + + @beartype def _model_count_details( *, @@ -722,6 +851,7 @@ def _validate_dataset_request( _model_field_details(models=models, dataset_type=dataset_type) or _view_details(models=models) or _guide_view_position_details(models=models) + or _state_based_details(models=models) or _model_count_details( models=models, dataset_type=dataset_type, diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 9d6c9b6c0..b82079420 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -443,6 +443,14 @@ def _setup_model_target_backend( setup_for=_setup_model_target_backend, ) +# Model Target Web API tests which need scopes that the real test account does +# not have, run against each mock only. +fixture_model_target_mock_only_vuforia = backend_fixture( + name="model_target_mock_only_vuforia", + backends=_MOCK_BACKENDS, + setup_for=_setup_model_target_backend, +) + # Tests which use this are run against each mock, and not against the # real Vuforia. This is useful for testing the mock using fixtures which # connect to Vuforia. diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index 6961c9196..f71e35cad 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -106,6 +106,17 @@ def _blob_dataset_request() -> dict[str, Any]: "models": [_MODEL], } +_STATE_CONFIGURATION = json.dumps( + obj={ + "version": "1.0", + "default_state": "assembled", + "states": { + "assembled": {"base_scene": 0}, + "disassembled": {"base_scene": 0}, + }, + }, +) + @beartype def _assert_oauth2_error( @@ -1036,6 +1047,173 @@ class TestMockOnlyErrors: currently available test account and are kept mock-only by design. """ + @staticmethod + @pytest.mark.parametrize( + argnames="dataset_path", + argvalues=[ + pytest.param("/modeltargets/datasets", id="standard"), + pytest.param( + "/modeltargets/advancedDatasets", + id="advanced", + ), + ], + ) + @pytest.mark.parametrize( + argnames="view_updates", + argvalues=[ + pytest.param({}, id="all-states"), + pytest.param( + {"states": ["assembled"]}, + id="selected-states", + ), + ], + ) + def test_state_based_dataset( + *, + model_target_mock_only_vuforia: VuforiaBackend, + dataset_path: str, + view_updates: dict[str, object], + ) -> None: + """State-Based Model Target fields survive a dataset round + trip. + """ + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "stateBasedConfigurationJsonString": ( + _STATE_CONFIGURATION + ), + "views": [{**_VIEW, **view_updates}], + }, + ], + } + access_token = _access_token_for_backend( + backend=model_target_mock_only_vuforia, + ) + headers = {"Authorization": f"Bearer {access_token}"} + create_response = requests.post( + url=f"{_VWS_HOST}{dataset_path}", + headers=headers, + json=body, + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + dataset_uuid = create_response.json()["uuid"] + delete_response = requests.delete( + url=f"{_VWS_HOST}{dataset_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert delete_response.status_code == HTTPStatus.OK + + @staticmethod + @pytest.mark.parametrize( + argnames=("model_updates", "view_updates", "expected_message"), + argvalues=[ + pytest.param( + {"stateBasedConfigurationJsonString": 1}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString: " + "error.expected.jsstring" + ), + id="configuration-not-string", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "{"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString: " + "error.expected.validjson" + ), + id="configuration-not-json", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "{}"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString/states: " + "error.expected.jsobject" + ), + id="configuration-states-not-object", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "[]"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString/states: " + "error.expected.jsobject" + ), + id="configuration-not-object", + ), + pytest.param( + {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, + {"states": "assembled"}, + "/models(0)/views(0)/states: error.expected.jsarray", + id="view-states-not-array", + ), + pytest.param( + {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, + {"states": ["assembled", 1]}, + ("/models(0)/views(0)/states(1): error.expected.jsstring"), + id="view-state-not-string", + ), + pytest.param( + {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, + {"states": ["unknown"]}, + ("/models(0)/views(0)/states(0): error.expected.validenum"), + id="view-state-not-declared", + ), + pytest.param( + {}, + {"states": ["assembled"]}, + ( + "/models(0)/stateBasedConfigurationJsonString: element " + "is required when view states are given" + ), + id="view-states-without-configuration", + ), + ], + ) + def test_invalid_state_based_dataset( + *, + model_target_mock_only_vuforia: VuforiaBackend, + model_updates: dict[str, object], + view_updates: dict[str, object], + expected_message: str, + ) -> None: + """Invalid State-Based Model Target fields are rejected.""" + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + **model_updates, + "views": [{**_VIEW, **view_updates}], + }, + ], + } + access_token = _access_token_for_backend( + backend=model_target_mock_only_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + expected_message, + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + @staticmethod def test_advanced_model_count_exceeds_limit() -> None: """Advanced dataset requests with too many models are rejected.