Files
cadSet/SimpleCADAPI/tools/scene_contract_fixtures/resource_cases.py

711 lines
21 KiB
Python

"""Resource-limit fixture case construction."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import replace
from typing import Any, Callable
from simplecadapi.scene import (
BASE_LIMITS,
SceneResourceLimits,
canonical_archive_size,
json_resource_issues,
preflight_aggregate_compression_ratio,
preflight_archive_member_sizes,
preflight_glb_counts,
preflight_input_archive_size,
preflight_member_compression_ratio,
resource_count_issues,
validate_scene_manifest,
with_scene_revision,
)
from simplecadapi.scene.validation import (
_compute_package_budget_totals,
_package_budget_issues,
_report,
)
from .common import JsonObject, first_issue
def _limits(overrides: dict[str, int]) -> SceneResourceLimits:
return replace(BASE_LIMITS, **overrides)
def _preflight_case(
name: str,
operation: str,
parameters: dict[str, object],
callback: Callable[[], object],
) -> dict[str, object]:
try:
callback()
except ValueError as exc:
valid, error = False, str(exc)
else:
valid, error = True, None
return {
"error": error,
"name": name,
"operation": operation,
"parameters": parameters,
"valid": valid,
}
def _issues_case(
name: str,
operation: str,
parameters: dict[str, object],
issues: tuple[Any, ...] | list[Any],
) -> dict[str, object]:
first = issues[0] if issues else None
return {
"expected": (
None if first is None else {"code": first.code, "path": first.path}
),
"name": name,
"operation": operation,
"parameters": parameters,
"valid": not issues,
}
def _validation_case(
name: str,
operation: str,
parameters: dict[str, object],
report: Any,
) -> dict[str, object]:
return {
"expected": first_issue(report),
"name": name,
"operation": operation,
"parameters": parameters,
"valid": report.valid,
}
def _input_size_case(name: str, size: int, overrides: dict[str, int]) -> dict[str, object]:
parameters: dict[str, object] = {"limits": overrides, "size": size}
return _preflight_case(
name,
"input_archive_size",
parameters,
lambda: preflight_input_archive_size(size, limits=_limits(overrides)),
)
def _archive_sizes_case(
name: str, sizes: dict[str, int], overrides: dict[str, int]
) -> dict[str, object]:
parameters: dict[str, object] = {"limits": overrides, "sizes": sizes}
return _preflight_case(
name,
"archive_member_sizes",
parameters,
lambda: preflight_archive_member_sizes(sizes, limits=_limits(overrides)),
)
def _archive_count_case(
name: str, count: int, overrides: dict[str, int]
) -> dict[str, object]:
sizes = {"scene.json": 0}
sizes.update({f"x/{index}": 0 for index in range(count - 1)})
parameters: dict[str, object] = {"count": count, "limits": overrides}
return _preflight_case(
name,
"archive_member_count",
parameters,
lambda: preflight_archive_member_sizes(sizes, limits=_limits(overrides)),
)
def _compression_case(
name: str,
operation: str,
uncompressed_size: int,
compressed_size: int,
) -> dict[str, object]:
overrides = {"compression_ratio": 2}
parameters = {
"compressed_size": compressed_size,
"limits": overrides,
"uncompressed_size": uncompressed_size,
}
if operation == "aggregate_compression_ratio":
callback = lambda: preflight_aggregate_compression_ratio(
uncompressed_size, compressed_size, limits=_limits(overrides)
)
else:
callback = lambda: preflight_member_compression_ratio(
uncompressed_size, compressed_size, limits=_limits(overrides)
)
return _preflight_case(name, operation, parameters, callback)
def _json_value(kind: str, text: str, field: str | None = None) -> object:
if kind == "value":
return {"value": text}
if kind == "object_key":
return {text: 0}
if kind == "metadata_key":
return {"metadata": {text: 0}}
if kind == "sdk_metadata_key":
return {"sdk_metadata": {text: 0}}
if kind == "identifier":
return {field or "node_id": text}
if kind == "identifier_array":
return {field or "component_path": [text]}
if kind == "uri":
return {"uri": text}
raise AssertionError(kind)
def _json_domain_case(
name: str,
kind: str,
text: str,
overrides: dict[str, int],
*,
field: str | None = None,
) -> dict[str, object]:
parameters: dict[str, object] = {
"field": field,
"kind": kind,
"limits": overrides,
"text": text,
}
issues = json_resource_issues(
_json_value(kind, text, field), limits=_limits(overrides)
)
return _issues_case(name, "json_domain", parameters, issues)
def _nested_value(depth: int) -> object:
value: dict[str, object] = {}
current = value
for _index in range(depth):
current["x"] = {}
current = current["x"] # type: ignore[assignment]
return value
def _json_depth_case(name: str, depth: int) -> dict[str, object]:
overrides = {"json_depth": 2}
parameters: dict[str, object] = {"depth": depth, "limits": overrides}
issues = json_resource_issues(_nested_value(depth), limits=_limits(overrides))
return _issues_case(name, "json_depth", parameters, issues)
def _resource_count_value(
artifact: str, kind: str, count: int, field: str | None
) -> object:
if kind == "collection":
assert field is not None
return {field: [None] * count}
if kind == "hierarchy":
return {"nodes": [{"source": {"component_path": ["x"] * count}}]}
if kind == "forwarded":
return {
"connectors": [
{
"anchor_kind": "forwarded",
"connector_snapshot_id": f"c{index}",
"forwarded_from": {
"source_connector_snapshot_id": f"c{index + 1}"
},
}
for index in range(count)
]
}
raise AssertionError((artifact, kind))
def _resource_count_case(
name: str,
artifact: str,
kind: str,
count: int,
overrides: dict[str, int],
*,
field: str | None = None,
) -> dict[str, object]:
parameters: dict[str, object] = {
"artifact": artifact,
"count": count,
"field": field,
"kind": kind,
"limits": overrides,
}
issues = resource_count_issues(
_resource_count_value(artifact, kind, count, field),
artifact,
limits=_limits(overrides),
)
return _issues_case(name, "resource_count", parameters, issues)
def _glb_count_case(
name: str,
kind: str,
vertex_count: int,
index_count: int,
overrides: dict[str, int],
) -> dict[str, object]:
parameters: dict[str, object] = {
"index_count": index_count,
"kind": kind,
"limits": overrides,
"vertex_count": vertex_count,
}
return _preflight_case(
name,
"glb_counts",
parameters,
lambda: preflight_glb_counts(
kind, # type: ignore[arg-type]
vertex_count,
index_count,
limits=_limits(overrides),
),
)
_BUDGET_FIELDS = {
"entities_total": "entity_count",
"line_segments_total": "line_segment_count",
"line_vertices_total": "line_vertex_count",
"static_decoded_buffer_bytes": "scene_json_bytes",
"triangle_vertices_total": "triangle_vertex_count",
"triangles_total": "triangle_count",
}
def _package_budget_case(
name: str, limit_name: str, value: int
) -> dict[str, object]:
contributions = {
"scene_json_bytes": 0,
"glb_decoded_buffer_bytes": 0,
"entity_json_bytes": 0,
"other_immutable_json_bytes": 0,
"entity_count": 0,
"entity_vertex_count": 0,
"triangle_vertex_count": 0,
"triangle_count": 0,
"line_vertex_count": 0,
"line_segment_count": 0,
}
contributions[_BUDGET_FIELDS[limit_name]] = value
overrides = {limit_name: 2}
totals = _compute_package_budget_totals(**contributions)
budget_report = _report(
_package_budget_issues(totals, limits=_limits(overrides)), artifact="package"
)
return _validation_case(
name,
"package_budget",
{"contributions": contributions, "limits": overrides},
budget_report,
)
def build_resource_cases(
scene: JsonObject,
normalized_part: JsonObject,
) -> list[dict[str, object]]:
input_limits = {"input_archive_bytes": 8}
role_limits = {
"canonical_archive_bytes": 1_000,
"one_member_bytes": 100,
"total_uncompressed_bytes": 100,
}
canonical_limits = {
"canonical_archive_bytes": 256,
"one_member_bytes": 100,
"total_uncompressed_bytes": 100,
}
canonical_envelope = canonical_archive_size(
{"geometry/a.glb": 0, "scene.json": 0}
)
canonical_payload = canonical_limits["canonical_archive_bytes"] - canonical_envelope
one_member_limits = {
"canonical_archive_bytes": 1_000,
"one_member_bytes": 8,
"total_uncompressed_bytes": 100,
}
total_limits = {
"canonical_archive_bytes": 1_000,
"one_member_bytes": 8,
"total_uncompressed_bytes": 16,
}
count_limits = {"canonical_archive_bytes": 10_000, "zip_members": 3}
cases = [
_input_size_case("input_archive_exact_limit", 8, input_limits),
_input_size_case("input_archive_over_limit", 9, input_limits),
_archive_sizes_case(
"scene_json_exact_limit",
{"scene.json": 8},
{**role_limits, "scene_json_bytes": 8},
),
_archive_sizes_case(
"scene_json_over_limit",
{"scene.json": 9},
{**role_limits, "scene_json_bytes": 8},
),
_archive_sizes_case(
"entity_json_exact_limit",
{"entities/a.json": 8, "scene.json": 0},
{**role_limits, "entity_json_bytes": 8},
),
_archive_sizes_case(
"entity_json_over_limit",
{"entities/a.json": 9, "scene.json": 0},
{**role_limits, "entity_json_bytes": 8},
),
_archive_sizes_case(
"model_json_exact_limit",
{"model/model.json": 8, "scene.json": 0},
{**role_limits, "model_json_bytes": 8},
),
_archive_sizes_case(
"model_json_over_limit",
{"model/model.json": 9, "scene.json": 0},
{**role_limits, "model_json_bytes": 8},
),
_archive_sizes_case(
"presentation_json_exact_limit",
{"presentation/presentation.json": 8, "scene.json": 0},
{**role_limits, "presentation_json_bytes": 8},
),
_archive_sizes_case(
"presentation_json_over_limit",
{"presentation/presentation.json": 9, "scene.json": 0},
{**role_limits, "presentation_json_bytes": 8},
),
_archive_sizes_case(
"canonical_archive_exact_limit",
{"geometry/a.glb": canonical_payload, "scene.json": 0},
canonical_limits,
),
_archive_sizes_case(
"canonical_archive_over_limit",
{"geometry/a.glb": canonical_payload + 1, "scene.json": 0},
canonical_limits,
),
_archive_sizes_case(
"one_member_exact_limit",
{"geometry/a.glb": 8, "scene.json": 0},
one_member_limits,
),
_archive_sizes_case(
"one_member_over_limit",
{"geometry/a.glb": 9, "scene.json": 0},
one_member_limits,
),
_archive_sizes_case(
"total_uncompressed_exact_limit",
{"geometry/a.glb": 8, "geometry/b.glb": 8, "scene.json": 0},
total_limits,
),
_archive_sizes_case(
"total_uncompressed_over_limit",
{
"geometry/a.glb": 8,
"geometry/b.glb": 8,
"geometry/c.glb": 1,
"scene.json": 0,
},
total_limits,
),
_archive_count_case("archive_member_count_exact_limit", 3, count_limits),
_archive_count_case("archive_member_count_over_limit", 4, count_limits),
_compression_case(
"aggregate_compression_ratio_exact_limit",
"aggregate_compression_ratio",
4,
2,
),
_compression_case(
"aggregate_compression_ratio_over_limit",
"aggregate_compression_ratio",
5,
2,
),
_compression_case(
"member_compression_ratio_exact_limit",
"member_compression_ratio",
4,
2,
),
_compression_case(
"member_compression_ratio_over_limit",
"member_compression_ratio",
5,
2,
),
_json_depth_case("json_depth_exact_limit", 2),
_json_depth_case("json_depth_over_limit", 3),
]
for kind, overrides in (
("value", {"json_string_bytes": 4}),
("object_key", {"json_string_bytes": 4}),
(
"metadata_key",
{"json_string_bytes": 100, "structural_id_bytes": 4},
),
(
"sdk_metadata_key",
{"json_string_bytes": 100, "structural_id_bytes": 4},
),
("uri", {"json_string_bytes": 100, "uri_bytes": 4}),
):
cases.append(
_json_domain_case(
f"utf8_{kind}_exact_limit", kind, "éé", overrides
)
)
cases.append(
_json_domain_case(
f"utf8_{kind}_over_limit", kind, "ééa", overrides
)
)
identifier_fields = (
"definition_ref",
"node_id",
"source_element_id",
"topo_id",
)
for field in identifier_fields:
overrides = {"json_string_bytes": 100, "structural_id_bytes": 4}
cases.append(
_json_domain_case(
f"utf8_{field}_exact_limit",
"identifier",
"éé",
overrides,
field=field,
)
)
cases.append(
_json_domain_case(
f"utf8_{field}_over_limit",
"identifier",
"ééa",
overrides,
field=field,
)
)
for field in (
"child_entity_ids",
"component_path",
"evaluated_tags",
"grounded_component_ids",
"parent_entity_ids",
"semantic_binding_ids",
):
overrides = {"json_string_bytes": 100, "structural_id_bytes": 4}
cases.append(
_json_domain_case(
f"utf8_{field}_item_exact_limit",
"identifier_array",
"éé",
overrides,
field=field,
)
)
cases.append(
_json_domain_case(
f"utf8_{field}_item_over_limit",
"identifier_array",
"ééa",
overrides,
field=field,
)
)
collection_limits = (
("scene", "definitions", "definitions"),
("scene", "nodes", "nodes"),
("scene", "geometry_assets", "assets_per_kind"),
("scene", "edge_assets", "assets_per_kind"),
("scene", "entity_assets", "assets_per_kind"),
("scene", "appearances", "appearances"),
("scene", "connectors", "connectors"),
("scene", "cameras", "cameras"),
("entities", "entities", "entities_per_sidecar"),
("entities", "face_groups", "entities_per_sidecar"),
("entities", "edge_groups", "entities_per_sidecar"),
("presentation", "node_overrides", "nodes"),
("presentation", "appearances", "appearances"),
("presentation", "cameras", "cameras"),
)
for artifact, field, limit_name in collection_limits:
overrides = {limit_name: 2}
cases.append(
_resource_count_case(
f"{artifact}_{field}_exact_limit",
artifact,
"collection",
2,
overrides,
field=field,
)
)
cases.append(
_resource_count_case(
f"{artifact}_{field}_over_limit",
artifact,
"collection",
3,
overrides,
field=field,
)
)
cases.extend(
[
_resource_count_case(
"hierarchy_depth_exact_limit",
"scene",
"hierarchy",
2,
{"hierarchy_depth": 2, "nodes": 10},
),
_resource_count_case(
"hierarchy_depth_over_limit",
"scene",
"hierarchy",
3,
{"hierarchy_depth": 2, "nodes": 10},
),
_resource_count_case(
"forwarded_connector_depth_exact_limit",
"scene",
"forwarded",
2,
{"connectors": 10, "forwarded_connector_depth": 2},
),
_resource_count_case(
"forwarded_connector_depth_over_limit",
"scene",
"forwarded",
3,
{"connectors": 10, "forwarded_connector_depth": 2},
),
_glb_count_case(
"triangle_vertices_per_asset_exact_limit",
"triangle",
2,
3,
{"triangle_vertices_per_asset": 2},
),
_glb_count_case(
"triangle_vertices_per_asset_over_limit",
"triangle",
3,
3,
{"triangle_vertices_per_asset": 2},
),
_glb_count_case(
"triangles_per_asset_exact_limit",
"triangle",
3,
6,
{"triangles_per_asset": 2},
),
_glb_count_case(
"triangles_per_asset_over_limit",
"triangle",
3,
9,
{"triangles_per_asset": 2},
),
_glb_count_case(
"line_vertices_per_asset_exact_limit",
"line",
2,
2,
{"line_vertices_per_asset": 2},
),
_glb_count_case(
"line_vertices_per_asset_over_limit",
"line",
3,
2,
{"line_vertices_per_asset": 2},
),
_glb_count_case(
"line_segments_per_asset_exact_limit",
"line",
2,
4,
{"line_segments_per_asset": 2},
),
_glb_count_case(
"line_segments_per_asset_over_limit",
"line",
2,
6,
{"line_segments_per_asset": 2},
),
]
)
for limit_name in _BUDGET_FIELDS:
cases.append(
_package_budget_case(f"{limit_name}_exact_limit", limit_name, 2)
)
cases.append(
_package_budget_case(f"{limit_name}_over_limit", limit_name, 3)
)
for name, value_number in (
("safe_integer_exact_limit", 9_007_199_254_740_991),
("safe_integer_over_limit", 9_007_199_254_740_992),
):
value = deepcopy(scene)
value["geometry_assets"][0]["byte_length"] = value_number
if value_number <= 9_007_199_254_740_991:
value = with_scene_revision(value)
cases.append(
_validation_case(
name,
"scene_geometry_byte_length",
{"value": str(value_number)},
validate_scene_manifest(value),
)
)
for name, field, value_number in (
("linear_tolerance_exact_max", "linear_tolerance", 1_000_000),
("linear_tolerance_over_max", "linear_tolerance", 1_000_001),
("angular_tolerance_exact_max", "angular_tolerance", 3.141592653589793),
("angular_tolerance_over_max", "angular_tolerance", 3.1415926535897936),
):
value = deepcopy(scene)
value["compile_options"][field] = value_number
value["geometry_assets"][0]["tessellation"][field] = value_number
if field == "linear_tolerance":
value["edge_assets"][0]["tessellation"][field] = value_number
value = with_scene_revision(value)
cases.append(
_validation_case(
name,
"scene_compile_option",
{"field": field, "value": value_number},
validate_scene_manifest(value),
)
)
# Keep the argument in active use so fixture construction catches drift in
# the valid normalized product even though resource probes are synthetic.
assert normalized_part
return cases