Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/source/differences-to-vws.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:<numeric-user-id>`` where the numeric portion is per-account.
Expand All @@ -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
-------------------
Expand Down
1 change: 1 addition & 0 deletions newsfragments/model-target-state-fields.change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Accept State-Based Model Target configuration and validate per-view state selections against its declared states.
132 changes: 131 additions & 1 deletion src/mock_vws/_model_target_web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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(
*,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions tests/mock_vws/fixtures/vuforia_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading