1860 lines
69 KiB
Python
1860 lines
69 KiB
Python
import base64
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from copy import deepcopy
|
|
from dataclasses import FrozenInstanceError, asdict, is_dataclass, replace
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from jsonschema import Draft202012Validator
|
|
from referencing import Registry, Resource
|
|
from referencing.jsonschema import DRAFT202012
|
|
import simplecadapi.scene.archive as scene_archive
|
|
|
|
from simplecadapi.scene import (
|
|
BASE_LIMITS,
|
|
ConnectorBindingDocument,
|
|
EntityDocument,
|
|
NormalizedProductDocument,
|
|
PresentationDocument,
|
|
SCHEMA_FILES,
|
|
SceneContractError,
|
|
SceneDocument,
|
|
SceneResourceLimits,
|
|
canonical_json_bytes,
|
|
canonical_zip_bytes,
|
|
parse_canonical_json,
|
|
parse_strict_json,
|
|
preflight_aggregate_compression_ratio,
|
|
preflight_archive_member_sizes,
|
|
preflight_glb,
|
|
preflight_glb_counts,
|
|
preflight_input_archive_size,
|
|
preflight_member_compression_ratio,
|
|
preflight_zip_bytes,
|
|
profile_cross,
|
|
profile_f32_bits,
|
|
profile_normalize,
|
|
validate_entity_asset,
|
|
validate_connector_binding,
|
|
validate_normalized_product,
|
|
validate_presentation,
|
|
validate_scene_manifest,
|
|
validate_scene_package,
|
|
json_resource_issues,
|
|
resource_count_issues,
|
|
with_scene_revision,
|
|
)
|
|
from simplecadapi.scene.validation import (
|
|
_compute_package_budget_totals,
|
|
_issue,
|
|
_package_budget_issues,
|
|
_report,
|
|
_validate_rule_registry,
|
|
load_contract_artifact,
|
|
)
|
|
from scene_contract_corpus_support import (
|
|
apply_scene_field_case,
|
|
corpus_blobs,
|
|
scene_shape_facts,
|
|
)
|
|
|
|
|
|
IDENTITY = {
|
|
"origin": [0, 0, 0],
|
|
"x_axis": [1, 0, 0],
|
|
"y_axis": [0, 1, 0],
|
|
"z_axis": [0, 0, 1],
|
|
}
|
|
ASSET_TO_SCENE = [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]
|
|
|
|
|
|
def _f32(value):
|
|
result = struct.unpack("<f", struct.pack("<f", value))[0]
|
|
return 0.0 if result == 0 else result
|
|
|
|
|
|
def _normalize(vector):
|
|
length = math.sqrt(sum(component * component for component in vector))
|
|
return tuple(component / length for component in vector)
|
|
|
|
|
|
def _cross(left, right):
|
|
return (
|
|
left[1] * right[2] - left[2] * right[1],
|
|
left[2] * right[0] - left[0] * right[2],
|
|
left[0] * right[1] - left[1] * right[0],
|
|
)
|
|
|
|
|
|
def _frame(origin, x_axis):
|
|
x_axis = _normalize(x_axis)
|
|
seed = (0.0, 0.0, 1.0) if abs(x_axis[2]) < 0.9 else (0.0, 1.0, 0.0)
|
|
projection = sum(left * right for left, right in zip(seed, x_axis))
|
|
y_axis = _normalize(tuple(seed[index] - projection * x_axis[index] for index in range(3)))
|
|
z_axis = _normalize(_cross(x_axis, y_axis))
|
|
return {
|
|
"origin": list(origin),
|
|
"x_axis": list(x_axis),
|
|
"y_axis": list(y_axis),
|
|
"z_axis": list(z_axis),
|
|
}
|
|
|
|
|
|
def _glb(kind, positions, indices, normals=None):
|
|
positions = [tuple(_f32(component) for component in point) for point in positions]
|
|
position_bytes = b"".join(struct.pack("<fff", *point) for point in positions)
|
|
chunks = [position_bytes]
|
|
views = [
|
|
{"buffer": 0, "byteLength": len(position_bytes), "byteOffset": 0, "target": 34962}
|
|
]
|
|
accessors = [
|
|
{
|
|
"bufferView": 0,
|
|
"componentType": 5126,
|
|
"count": len(positions),
|
|
"max": [max(point[axis] for point in positions) for axis in range(3)],
|
|
"min": [min(point[axis] for point in positions) for axis in range(3)],
|
|
"type": "VEC3",
|
|
}
|
|
]
|
|
if kind == "triangle":
|
|
assert normals is not None
|
|
normals = [tuple(_f32(component) for component in vector) for vector in normals]
|
|
normal_bytes = b"".join(struct.pack("<fff", *vector) for vector in normals)
|
|
views.append(
|
|
{
|
|
"buffer": 0,
|
|
"byteLength": len(normal_bytes),
|
|
"byteOffset": len(position_bytes),
|
|
"target": 34962,
|
|
}
|
|
)
|
|
accessors.append(
|
|
{
|
|
"bufferView": 1,
|
|
"componentType": 5126,
|
|
"count": len(normals),
|
|
"type": "VEC3",
|
|
}
|
|
)
|
|
chunks.append(normal_bytes)
|
|
index_view = 2
|
|
attributes = {"NORMAL": 1, "POSITION": 0}
|
|
mode = 4
|
|
else:
|
|
index_view = 1
|
|
attributes = {"POSITION": 0}
|
|
mode = 1
|
|
prefix = b"".join(chunks)
|
|
prefix += b"\0" * ((-len(prefix)) % 4)
|
|
component_type = 5123 if len(positions) <= 65536 else 5125
|
|
index_format = "H" if component_type == 5123 else "I"
|
|
index_bytes = struct.pack("<" + index_format * len(indices), *indices)
|
|
views.append(
|
|
{
|
|
"buffer": 0,
|
|
"byteLength": len(index_bytes),
|
|
"byteOffset": len(prefix),
|
|
"target": 34963,
|
|
}
|
|
)
|
|
accessors.append(
|
|
{
|
|
"bufferView": index_view,
|
|
"componentType": component_type,
|
|
"count": len(indices),
|
|
"type": "SCALAR",
|
|
}
|
|
)
|
|
unpadded_bin = prefix + index_bytes
|
|
document = {
|
|
"accessors": accessors,
|
|
"asset": {"generator": "SimpleCAD Scene GLB Profile 1", "version": "2.0"},
|
|
"bufferViews": views,
|
|
"buffers": [{"byteLength": len(unpadded_bin)}],
|
|
"meshes": [
|
|
{"primitives": [{"attributes": attributes, "indices": index_view, "mode": mode}]}
|
|
],
|
|
"nodes": [{"mesh": 0}],
|
|
"scene": 0,
|
|
"scenes": [{"nodes": [0]}],
|
|
}
|
|
json_bytes = canonical_json_bytes(document)
|
|
json_chunk = json_bytes + b" " * ((-len(json_bytes)) % 4)
|
|
bin_chunk = unpadded_bin + b"\0" * ((-len(unpadded_bin)) % 4)
|
|
total_length = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk)
|
|
return b"".join(
|
|
(
|
|
struct.pack("<III", 0x46546C67, 2, total_length),
|
|
struct.pack("<II", len(json_chunk), 0x4E4F534A),
|
|
json_chunk,
|
|
struct.pack("<II", len(bin_chunk), 0x004E4942),
|
|
bin_chunk,
|
|
)
|
|
)
|
|
|
|
|
|
def _canonical_triangle_asset():
|
|
points = {
|
|
"a": (0.0, 0.0, 0.0),
|
|
"b": (1.0, 0.0, 0.0),
|
|
"c": (0.0, 1.0, 0.0),
|
|
"d": (0.0, 0.0, 1.0),
|
|
}
|
|
oriented_faces = {
|
|
"abc": ("a", "c", "b"),
|
|
"abd": ("a", "b", "d"),
|
|
"acd": ("a", "d", "c"),
|
|
"bcd": ("b", "c", "d"),
|
|
}
|
|
blocks = []
|
|
for name, corners in oriented_faces.items():
|
|
a, b, c = (points[corner] for corner in corners)
|
|
normal = _normalize(_cross(tuple(b[i] - a[i] for i in range(3)), tuple(c[i] - a[i] for i in range(3))))
|
|
local_positions = sorted(
|
|
(points[corner] for corner in corners), key=lambda point: struct.pack("<fff", *point)
|
|
)
|
|
remap = {point: index for index, point in enumerate(local_positions)}
|
|
triple = tuple(remap[points[corner]] for corner in corners)
|
|
rotations = (triple, triple[1:] + triple[:1], triple[2:] + triple[:2])
|
|
triple = min(rotations)
|
|
block = b"".join(
|
|
struct.pack("<ffffff", *point, *normal) for point in local_positions
|
|
) + struct.pack("<HHH", *triple)
|
|
blocks.append((hashlib.sha256(b"\x01" + block).digest(), block, name, local_positions, normal, triple))
|
|
blocks.sort(key=lambda item: (item[0], item[1]))
|
|
positions = []
|
|
normals = []
|
|
indices = []
|
|
group_names = []
|
|
for _render_key, _block, name, local_positions, normal, triple in blocks:
|
|
offset = len(positions)
|
|
positions.extend(local_positions)
|
|
normals.extend([normal] * len(local_positions))
|
|
indices.extend(offset + index for index in triple)
|
|
group_names.append(name)
|
|
return _glb("triangle", positions, indices, normals), group_names
|
|
|
|
|
|
def _canonical_line_asset():
|
|
points = {
|
|
"a": (0.0, 0.0, 0.0),
|
|
"b": (1.0, 0.0, 0.0),
|
|
"c": (0.0, 1.0, 0.0),
|
|
"d": (0.0, 0.0, 1.0),
|
|
}
|
|
blocks = []
|
|
for name in ("ab", "ac", "ad", "bc", "bd", "cd"):
|
|
local_positions = sorted(
|
|
(points[name[0]], points[name[1]]), key=lambda point: struct.pack("<fff", *point)
|
|
)
|
|
block = b"".join(struct.pack("<fff", *point) for point in local_positions)
|
|
block += struct.pack("<HH", 0, 1)
|
|
blocks.append((hashlib.sha256(b"\x02" + block).digest(), block, name, local_positions))
|
|
blocks.sort(key=lambda item: (item[0], item[1]))
|
|
positions = []
|
|
indices = []
|
|
group_names = []
|
|
for _render_key, _block, name, local_positions in blocks:
|
|
offset = len(positions)
|
|
positions.extend(local_positions)
|
|
indices.extend((offset, offset + 1))
|
|
group_names.append(name)
|
|
return _glb("line", positions, indices), group_names
|
|
|
|
|
|
def _entity(kind, index, parents, children, geometry, properties, frame, status):
|
|
return {
|
|
"child_entity_ids": sorted(children),
|
|
"connector_binding_status": status,
|
|
"entity_id": f"entity/{kind}/{index}",
|
|
"evaluated_tags": [],
|
|
"geometry": geometry,
|
|
"kind": kind,
|
|
"parent_entity_ids": sorted(parents),
|
|
"properties": properties,
|
|
"render_status": "rendered",
|
|
"sdk_connector_frame": frame,
|
|
"sdk_metadata": {},
|
|
"semantic_binding_ids": [],
|
|
"source": {"kind": "unbound"},
|
|
}
|
|
|
|
|
|
def _build_scene_package():
|
|
triangle_glb, face_order = _canonical_triangle_asset()
|
|
line_glb, edge_order = _canonical_line_asset()
|
|
geometry_hash = "sha256:" + hashlib.sha256(triangle_glb).hexdigest()
|
|
edge_hash = "sha256:" + hashlib.sha256(line_glb).hexdigest()
|
|
face_ids = {name: f"entity/face/{index}" for index, name in enumerate(face_order)}
|
|
edge_ids = {name: f"entity/edge/{index}" for index, name in enumerate(edge_order)}
|
|
vertex_ids = {name: f"entity/vertex/{index}" for index, name in enumerate("abcd")}
|
|
cad_points = {
|
|
"a": (0.0, 0.0, 0.0),
|
|
"b": (1000.0, 0.0, 0.0),
|
|
"c": (0.0, 0.0, 1000.0),
|
|
"d": (0.0, -1000.0, 0.0),
|
|
}
|
|
face_vertices = {
|
|
"abc": "abc",
|
|
"abd": "abd",
|
|
"acd": "acd",
|
|
"bcd": "bcd",
|
|
}
|
|
face_edges = {
|
|
"abc": ("ab", "ac", "bc"),
|
|
"abd": ("ab", "ad", "bd"),
|
|
"acd": ("ac", "ad", "cd"),
|
|
"bcd": ("bc", "bd", "cd"),
|
|
}
|
|
edge_faces = {
|
|
edge: tuple(name for name, edges in face_edges.items() if edge in edges)
|
|
for edge in edge_order
|
|
}
|
|
vertex_edges = {
|
|
vertex: tuple(edge for edge in edge_order if vertex in edge)
|
|
for vertex in "abcd"
|
|
}
|
|
entities = []
|
|
entities.append(
|
|
_entity(
|
|
"solid",
|
|
0,
|
|
[],
|
|
list(face_ids.values()),
|
|
{"type": "brep_solid"},
|
|
{
|
|
"bounds": {"max": [1000, 0, 1000], "min": [0, -1000, 0]},
|
|
"centroid": [250, -250, 250],
|
|
"quality": "kernel_evaluated",
|
|
"surface_area": 1_500_000 + 500_000 * math.sqrt(3),
|
|
"volume": 1_000_000_000 / 6,
|
|
},
|
|
None,
|
|
"not_applicable",
|
|
)
|
|
)
|
|
face_normals = {
|
|
"abc": (0.0, 1.0, 0.0),
|
|
"abd": (0.0, 0.0, -1.0),
|
|
"acd": (-1.0, 0.0, 0.0),
|
|
"bcd": _normalize((1.0, -1.0, 1.0)),
|
|
}
|
|
for name in face_order:
|
|
vertices = [cad_points[vertex] for vertex in face_vertices[name]]
|
|
minimum = [min(point[axis] for point in vertices) for axis in range(3)]
|
|
maximum = [max(point[axis] for point in vertices) for axis in range(3)]
|
|
centroid = [sum(point[axis] for point in vertices) / 3 for axis in range(3)]
|
|
edge_vector = tuple(vertices[1][axis] - vertices[0][axis] for axis in range(3))
|
|
frame = _frame(centroid, edge_vector)
|
|
frame["z_axis"] = list(face_normals[name])
|
|
frame["y_axis"] = list(_normalize(_cross(face_normals[name], frame["x_axis"])))
|
|
entities.append(
|
|
_entity(
|
|
"face",
|
|
int(face_ids[name].rsplit("/", 1)[1]),
|
|
["entity/solid/0"],
|
|
[edge_ids[edge] for edge in face_edges[name]],
|
|
{
|
|
"normal": list(face_normals[name]),
|
|
"origin": centroid,
|
|
"type": "plane",
|
|
"x_direction": frame["x_axis"],
|
|
},
|
|
{
|
|
"area": 500_000 * (math.sqrt(3) if name == "bcd" else 1),
|
|
"bounds": {"max": maximum, "min": minimum},
|
|
"centroid": centroid,
|
|
"orientation": "forward",
|
|
"quality": "kernel_evaluated",
|
|
},
|
|
frame,
|
|
"owner_not_part",
|
|
)
|
|
)
|
|
for name in edge_order:
|
|
start, end = (cad_points[vertex] for vertex in name)
|
|
direction = tuple(end[axis] - start[axis] for axis in range(3))
|
|
midpoint = [(start[axis] + end[axis]) / 2 for axis in range(3)]
|
|
minimum = [min(start[axis], end[axis]) for axis in range(3)]
|
|
maximum = [max(start[axis], end[axis]) for axis in range(3)]
|
|
entities.append(
|
|
_entity(
|
|
"edge",
|
|
int(edge_ids[name].rsplit("/", 1)[1]),
|
|
[face_ids[face] for face in edge_faces[name]],
|
|
[vertex_ids[vertex] for vertex in name],
|
|
{"direction": list(_normalize(direction)), "origin": list(start), "type": "line"},
|
|
{
|
|
"bounds": {"max": maximum, "min": minimum},
|
|
"centroid": midpoint,
|
|
"length": math.sqrt(sum(component * component for component in direction)),
|
|
"quality": "kernel_evaluated",
|
|
},
|
|
_frame(midpoint, direction),
|
|
"owner_not_part",
|
|
)
|
|
)
|
|
for name in "abcd":
|
|
point = cad_points[name]
|
|
entities.append(
|
|
_entity(
|
|
"vertex",
|
|
int(vertex_ids[name].rsplit("/", 1)[1]),
|
|
[edge_ids[edge] for edge in vertex_edges[name]],
|
|
[],
|
|
{"position": list(point), "type": "point"},
|
|
{
|
|
"bounds": {"max": list(point), "min": list(point)},
|
|
"position": list(point),
|
|
"quality": "kernel_evaluated",
|
|
},
|
|
{**IDENTITY, "origin": list(point)},
|
|
"owner_not_part",
|
|
)
|
|
)
|
|
definition_id = "definition/root/shape/manual/fixture"
|
|
entity_asset = {
|
|
"definition_id": definition_id,
|
|
"edge_asset_id": edge_hash,
|
|
"edge_groups": [
|
|
{
|
|
"entity_id": edge_ids[name],
|
|
"first_index": 2 * index,
|
|
"group_id": index,
|
|
"index_count": 2,
|
|
"mesh_index": 0,
|
|
"primitive_index": 0,
|
|
}
|
|
for index, name in enumerate(edge_order)
|
|
],
|
|
"entities": entities,
|
|
"face_groups": [
|
|
{
|
|
"entity_id": face_ids[name],
|
|
"first_index": 3 * index,
|
|
"group_id": index,
|
|
"index_count": 3,
|
|
"mesh_index": 0,
|
|
"primitive_index": 0,
|
|
}
|
|
for index, name in enumerate(face_order)
|
|
],
|
|
"geometry_asset_id": geometry_hash,
|
|
"geometry_engine": {
|
|
"name": "OpenCascade",
|
|
"profile": "ocp-evaluated-properties-1",
|
|
"version": "fixture",
|
|
},
|
|
"schema_version": "1.0",
|
|
}
|
|
entity_asset["entities"].sort(key=lambda entity: entity["entity_id"].encode("utf-8"))
|
|
entity_bytes = canonical_json_bytes(entity_asset)
|
|
entity_hash = "sha256:" + hashlib.sha256(entity_bytes).hexdigest()
|
|
appearance_draft = {
|
|
"alpha_mode": "opaque",
|
|
"base_color": [0.72, 0.75, 0.78, 1],
|
|
"double_sided": False,
|
|
"edge_color": [0.08, 0.09, 0.1, 1],
|
|
"metallic": 0,
|
|
"name": None,
|
|
"roughness": 0.55,
|
|
"sdk_metadata": {},
|
|
"source": None,
|
|
}
|
|
appearance_id = "appearance/evaluated/" + hashlib.sha256(
|
|
canonical_json_bytes(appearance_draft)
|
|
).hexdigest()
|
|
appearance = {"appearance_id": appearance_id, **appearance_draft}
|
|
geometry_uri = f"geometry/sha256-{geometry_hash.removeprefix('sha256:')}.glb"
|
|
edge_uri = f"edges/sha256-{edge_hash.removeprefix('sha256:')}.glb"
|
|
entity_uri = f"entities/sha256-{entity_hash.removeprefix('sha256:')}.json"
|
|
bounds = {"max": [1000, 0, 1000], "min": [0, -1000, 0]}
|
|
scene = with_scene_revision(
|
|
{
|
|
"annotations": [],
|
|
"appearances": [appearance],
|
|
"cameras": [],
|
|
"compile_options": {
|
|
"angular_tolerance": 0.22,
|
|
"embed_presentation": False,
|
|
"embed_source": False,
|
|
"linear_tolerance": 0.35,
|
|
},
|
|
"connectors": [],
|
|
"coordinate_system": {"handedness": "right", "length_unit": "mm", "up_axis": "+Z"},
|
|
"definitions": [
|
|
{
|
|
"appearance_id": appearance_id,
|
|
"definition_id": definition_id,
|
|
"edge_asset_id": edge_hash,
|
|
"entity_asset_id": entity_hash,
|
|
"geometry_asset_id": geometry_hash,
|
|
"kind": "shape",
|
|
"name": None,
|
|
"sdk_metadata": {},
|
|
"source": {"kind": "manual", "root_id": "root", "source_id": "fixture"},
|
|
}
|
|
],
|
|
"diagnostics": [],
|
|
"edge_assets": [
|
|
{
|
|
"asset_id": edge_hash,
|
|
"asset_to_scene": ASSET_TO_SCENE,
|
|
"byte_length": len(line_glb),
|
|
"content_hash": edge_hash,
|
|
"media_type": "model/gltf-binary",
|
|
"scene_local_bounds": bounds,
|
|
"tessellation": {"linear_tolerance": 0.35},
|
|
"uri": edge_uri,
|
|
}
|
|
],
|
|
"entity_assets": [
|
|
{
|
|
"byte_length": len(entity_bytes),
|
|
"content_hash": entity_hash,
|
|
"entity_asset_id": entity_hash,
|
|
"media_type": "application/vnd.simplecad.entities+json",
|
|
"uri": entity_uri,
|
|
}
|
|
],
|
|
"extensions": {},
|
|
"extensions_required": [],
|
|
"extensions_used": [],
|
|
"generator": {
|
|
"name": "simplecadapi",
|
|
"ocp_bindings_version": "fixture",
|
|
"ocp_version": "fixture",
|
|
"platform_tag": "fixture",
|
|
"profile": "scene-1.0-ocp-glb-2",
|
|
"python_abi": "fixture",
|
|
"simplecadapi_version": "fixture",
|
|
"toolchain_hash": "sha256:" + "0" * 64,
|
|
},
|
|
"geometry_assets": [
|
|
{
|
|
"asset_id": geometry_hash,
|
|
"asset_to_scene": ASSET_TO_SCENE,
|
|
"byte_length": len(triangle_glb),
|
|
"content_hash": geometry_hash,
|
|
"media_type": "model/gltf-binary",
|
|
"scene_local_bounds": bounds,
|
|
"tessellation": {"angular_tolerance": 0.22, "linear_tolerance": 0.35},
|
|
"uri": geometry_uri,
|
|
}
|
|
],
|
|
"lights": [],
|
|
"nodes": [
|
|
{
|
|
"appearance_override_id": None,
|
|
"definition_id": definition_id,
|
|
"name": None,
|
|
"node_id": "instance/root",
|
|
"order": 0,
|
|
"parent_node_id": None,
|
|
"sdk_metadata": {},
|
|
"selectable": True,
|
|
"source": {"kind": "shape_root", "root_id": "root"},
|
|
"transform": IDENTITY,
|
|
"visible": True,
|
|
}
|
|
],
|
|
"scene_id": "fixture",
|
|
"schema_version": "1.0",
|
|
"source": {"kind": "manual", "source_id": "fixture"},
|
|
}
|
|
)
|
|
scene_bytes = canonical_json_bytes(scene)
|
|
blobs = {geometry_uri: triangle_glb, edge_uri: line_glb, entity_uri: entity_bytes}
|
|
return scene, scene_bytes, entity_asset, entity_bytes, blobs
|
|
|
|
|
|
def _attach_presentation(scene, blobs):
|
|
presentation = {
|
|
"appearances": [
|
|
{
|
|
"alpha_mode": "opaque",
|
|
"base_color": [1, 0.5, 0.1, 1],
|
|
"double_sided": False,
|
|
"edge_color": [0.1, 0.05, 0, 1],
|
|
"metallic": 0,
|
|
"name": "Highlight",
|
|
"roughness": 0.4,
|
|
}
|
|
],
|
|
"cameras": [
|
|
{
|
|
"far": 10000,
|
|
"name": "Overview",
|
|
"near": 1,
|
|
"parent_node_id": "instance/root",
|
|
"projection": "perspective",
|
|
"transform": IDENTITY,
|
|
"vertical_fov_degrees": 45,
|
|
}
|
|
],
|
|
"node_overrides": [
|
|
{
|
|
"appearance_name": "Highlight",
|
|
"node_id": "instance/root",
|
|
"visible": False,
|
|
}
|
|
],
|
|
"presentation_id": "fixture-presentation",
|
|
"schema_version": "1.0",
|
|
"source_scene_id": scene["scene_id"],
|
|
}
|
|
authored = presentation["appearances"][0]
|
|
evaluated_draft = {
|
|
"alpha_mode": authored["alpha_mode"],
|
|
"base_color": authored["base_color"],
|
|
"double_sided": authored["double_sided"],
|
|
"edge_color": authored["edge_color"],
|
|
"metallic": authored["metallic"],
|
|
"name": authored["name"],
|
|
"roughness": authored["roughness"],
|
|
"sdk_metadata": {},
|
|
"source": {
|
|
"appearance_name": authored["name"],
|
|
"kind": "presentation",
|
|
"presentation_id": presentation["presentation_id"],
|
|
},
|
|
}
|
|
appearance_id = "appearance/evaluated/" + hashlib.sha256(
|
|
canonical_json_bytes(evaluated_draft)
|
|
).hexdigest()
|
|
scene["appearances"].append({"appearance_id": appearance_id, **evaluated_draft})
|
|
scene["appearances"].sort(key=lambda appearance: appearance["appearance_id"].encode("utf-8"))
|
|
scene["nodes"][0]["visible"] = False
|
|
scene["nodes"][0]["appearance_override_id"] = appearance_id
|
|
scene["cameras"] = [
|
|
{"camera_id": "camera/fixture-presentation/Overview", **presentation["cameras"][0]}
|
|
]
|
|
presentation_bytes = canonical_json_bytes(presentation)
|
|
scene["compile_options"]["embed_presentation"] = True
|
|
scene["presentation_source"] = {
|
|
"artifact_hash": "sha256:" + hashlib.sha256(presentation_bytes).hexdigest(),
|
|
"embedded_artifact_byte_length": len(presentation_bytes),
|
|
"embedded_artifact_uri": "presentation/presentation.json",
|
|
"presentation_id": presentation["presentation_id"],
|
|
"schema_version": "1.0",
|
|
}
|
|
scene = with_scene_revision(scene)
|
|
blobs = dict(blobs)
|
|
blobs["presentation/presentation.json"] = presentation_bytes
|
|
return scene, presentation, blobs
|
|
|
|
|
|
def _replace_embedded_presentation(scene, blobs, payload):
|
|
scene["presentation_source"]["artifact_hash"] = (
|
|
"sha256:" + hashlib.sha256(payload).hexdigest()
|
|
)
|
|
scene["presentation_source"]["embedded_artifact_byte_length"] = len(payload)
|
|
scene = with_scene_revision(scene)
|
|
blobs = dict(blobs)
|
|
blobs["presentation/presentation.json"] = payload
|
|
return scene, blobs
|
|
|
|
|
|
def _codes(report):
|
|
return [issue.code for issue in report.issues]
|
|
|
|
|
|
def _load_shared_corpus():
|
|
path = Path(__file__).parent / "fixtures" / "scene-contract" / "corpus.json"
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_generated_python_scene_contract_types_are_current():
|
|
subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(Path(__file__).parents[1] / "tools/generate_scene_contract_types.py"),
|
|
"--check",
|
|
"--language",
|
|
"python",
|
|
],
|
|
cwd=Path(__file__).parents[1],
|
|
check=True,
|
|
)
|
|
|
|
|
|
def _decode_base64(value):
|
|
return base64.b64decode(value, validate=True)
|
|
|
|
|
|
def _f64_from_hex(value):
|
|
return struct.unpack(">d", bytes.fromhex(value))[0]
|
|
|
|
|
|
def _f64_hex(value):
|
|
return struct.pack(">d", value).hex()
|
|
|
|
|
|
def _first_report_issue(report):
|
|
issue = report.first_error
|
|
return None if issue is None else {"code": issue.code, "path": issue.path}
|
|
|
|
|
|
def _replace_glb_json(glb, mutate):
|
|
json_length = struct.unpack_from("<I", glb, 12)[0]
|
|
document = parse_strict_json(glb[20 : 20 + json_length].rstrip(b" "))
|
|
mutate(document)
|
|
replacement = canonical_json_bytes(document)
|
|
replacement += b" " * ((-len(replacement)) % 4)
|
|
bin_header = 20 + json_length
|
|
bin_chunk = glb[bin_header:]
|
|
result = b"".join(
|
|
(
|
|
struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(replacement) + len(bin_chunk)),
|
|
struct.pack("<II", len(replacement), 0x4E4F534A),
|
|
replacement,
|
|
bin_chunk,
|
|
)
|
|
)
|
|
return result
|
|
|
|
|
|
def _declared_deflate_zip(compressed, uncompressed_size, *, local_uncompressed_size=None):
|
|
name = b"scene.json"
|
|
if local_uncompressed_size is None:
|
|
local_uncompressed_size = uncompressed_size
|
|
local = struct.pack(
|
|
"<IHHHHHIIIHH",
|
|
0x04034B50,
|
|
20,
|
|
0x0800,
|
|
8,
|
|
0,
|
|
0x0021,
|
|
0,
|
|
len(compressed),
|
|
local_uncompressed_size,
|
|
len(name),
|
|
0,
|
|
)
|
|
central_offset = len(local) + len(name) + len(compressed)
|
|
central = struct.pack(
|
|
"<IHHHHHHIIIHHHHHII",
|
|
0x02014B50,
|
|
0x0314,
|
|
20,
|
|
0x0800,
|
|
8,
|
|
0,
|
|
0x0021,
|
|
0,
|
|
len(compressed),
|
|
uncompressed_size,
|
|
len(name),
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0x81A40000,
|
|
0,
|
|
) + name
|
|
eocd = struct.pack(
|
|
"<IHHHHIIH",
|
|
0x06054B50,
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
len(central),
|
|
central_offset,
|
|
0,
|
|
)
|
|
return local + name + compressed + central + eocd
|
|
|
|
|
|
def test_strict_and_canonical_json_reject_ambiguous_encodings():
|
|
with pytest.raises(ValueError, match="duplicate"):
|
|
parse_strict_json(b'{"a":1,"a":2}')
|
|
with pytest.raises(ValueError, match="BOM"):
|
|
parse_strict_json(b"\xef\xbb\xbf{}")
|
|
with pytest.raises(ValueError, match="canonical"):
|
|
parse_canonical_json(b'{"b":1, "a":2}')
|
|
with pytest.raises(ValueError, match="surrogate"):
|
|
parse_strict_json(b'{"value":"\\ud800"}')
|
|
with pytest.raises(ValueError, match="surrogate"):
|
|
parse_strict_json(b'{"\\ud800":0}')
|
|
|
|
|
|
def test_packaged_contract_artifacts_validate_and_link_exact_pseudocode_hashes():
|
|
schema_paths = [
|
|
"schemas/scene-1.0.schema.json",
|
|
"schemas/entities-1.0.schema.json",
|
|
"schemas/presentation-1.0.schema.json",
|
|
"schemas/connector-binding-1.0.schema.json",
|
|
"schemas/normalized-product-1.schema.json",
|
|
"schemas/rules-1.schema.json",
|
|
"schemas/profile-1.schema.json",
|
|
"schemas/render-profile-2.schema.json",
|
|
"schemas/evaluated-properties-profile-1.schema.json",
|
|
]
|
|
schemas = {
|
|
path: parse_strict_json(load_contract_artifact(path)) for path in schema_paths
|
|
}
|
|
for schema in schemas.values():
|
|
Draft202012Validator.check_schema(schema)
|
|
rules_schema = parse_strict_json(load_contract_artifact("schemas/rules-1.schema.json"))
|
|
Draft202012Validator(rules_schema).validate(
|
|
parse_strict_json(load_contract_artifact("rules/scene-1.0-rules.json"))
|
|
)
|
|
profile_schemas = {
|
|
"render_asset": schemas["schemas/render-profile-2.schema.json"],
|
|
"evaluated_properties": schemas[
|
|
"schemas/evaluated-properties-profile-1.schema.json"
|
|
],
|
|
}
|
|
registry = Registry().with_resources(
|
|
(
|
|
schema["$id"],
|
|
Resource.from_contents(schema, default_specification=DRAFT202012),
|
|
)
|
|
for schema in profile_schemas.values()
|
|
)
|
|
dispatcher = Draft202012Validator(
|
|
schemas["schemas/profile-1.schema.json"], registry=registry
|
|
)
|
|
profile_cases = (
|
|
(
|
|
"profiles/scene-1.0-ocp-glb-2.profile.json",
|
|
"scene-1.0-ocp-glb-2",
|
|
"render_asset",
|
|
),
|
|
(
|
|
"profiles/ocp-evaluated-properties-1.profile.json",
|
|
"ocp-evaluated-properties-1",
|
|
"evaluated_properties",
|
|
),
|
|
)
|
|
for path, profile_id, profile_kind in profile_cases:
|
|
profile = parse_strict_json(load_contract_artifact(path))
|
|
applicable = Draft202012Validator(profile_schemas[profile_kind])
|
|
other_kind = next(kind for kind in profile_schemas if kind != profile_kind)
|
|
|
|
assert profile["profile_id"] == profile_id
|
|
assert profile["profile_kind"] == profile_kind
|
|
applicable.validate(profile)
|
|
dispatcher.validate(profile)
|
|
assert not Draft202012Validator(profile_schemas[other_kind]).is_valid(profile)
|
|
|
|
unknown_nested = deepcopy(profile)
|
|
unknown_nested["rules"]["implementation_status"]["unknown"] = True
|
|
assert not applicable.is_valid(unknown_nested)
|
|
assert not dispatcher.is_valid(unknown_nested)
|
|
|
|
deeply_unknown = deepcopy(profile)
|
|
if profile_kind == "render_asset":
|
|
deeply_unknown["rules"]["default_appearance"]["unknown"] = True
|
|
else:
|
|
deeply_unknown["rules"]["canonical_labeling"]["unknown"] = True
|
|
assert not applicable.is_valid(deeply_unknown)
|
|
assert not dispatcher.is_valid(deeply_unknown)
|
|
|
|
wrong_id = deepcopy(profile)
|
|
wrong_id["profile_id"] = "wrong-profile"
|
|
assert not applicable.is_valid(wrong_id)
|
|
assert not dispatcher.is_valid(wrong_id)
|
|
|
|
wrong_kind = deepcopy(profile)
|
|
wrong_kind["profile_kind"] = other_kind
|
|
assert not applicable.is_valid(wrong_kind)
|
|
assert not dispatcher.is_valid(wrong_kind)
|
|
|
|
pseudocode = load_contract_artifact(profile["pseudocode_uri"])
|
|
assert profile["pseudocode_hash"] == "sha256:" + hashlib.sha256(pseudocode).hexdigest()
|
|
|
|
|
|
def test_rule_registry_rejects_schema_and_ordering_invariant_violations():
|
|
schema = parse_strict_json(load_contract_artifact("schemas/rules-1.schema.json"))
|
|
registry = parse_strict_json(load_contract_artifact("rules/scene-1.0-rules.json"))
|
|
assert set(_validate_rule_registry(registry, schema)) == {
|
|
rule["id"] for rule in registry["rules"]
|
|
}
|
|
|
|
missing_precedence = deepcopy(registry)
|
|
missing_precedence["rules"][0].pop("precedence")
|
|
with pytest.raises(ValueError, match="precedence"):
|
|
_validate_rule_registry(missing_precedence, schema)
|
|
|
|
duplicate_id = deepcopy(registry)
|
|
duplicate_id["rules"][1]["id"] = duplicate_id["rules"][0]["id"]
|
|
with pytest.raises(ValueError, match="duplicate rule ID"):
|
|
_validate_rule_registry(duplicate_id, schema)
|
|
|
|
duplicate_precedence = deepcopy(registry)
|
|
duplicate_precedence["rules"][1]["precedence"] = 0
|
|
with pytest.raises(ValueError, match="duplicate precedence"):
|
|
_validate_rule_registry(duplicate_precedence, schema)
|
|
|
|
wrong_order = deepcopy(registry)
|
|
wrong_order["rules"][0], wrong_order["rules"][1] = (
|
|
wrong_order["rules"][1],
|
|
wrong_order["rules"][0],
|
|
)
|
|
with pytest.raises(ValueError, match="ordered by phase and precedence"):
|
|
_validate_rule_registry(wrong_order, schema)
|
|
|
|
wrong_id_order = deepcopy(registry)
|
|
wrong_id_order["rules"][0]["id"], wrong_id_order["rules"][1]["id"] = (
|
|
wrong_id_order["rules"][1]["id"],
|
|
wrong_id_order["rules"][0]["id"],
|
|
)
|
|
with pytest.raises(ValueError, match="unsigned UTF-8 rule-ID order"):
|
|
_validate_rule_registry(wrong_id_order, schema)
|
|
|
|
|
|
def test_rule_registry_controls_reports_and_closes_issue_authority():
|
|
report = _report(
|
|
[
|
|
_issue("transform_invalid", "/z", "later registered rule"),
|
|
_issue("bounds_invalid", "/z", "z message"),
|
|
_issue("bounds_invalid", "/z", "a message"),
|
|
_issue("bounds_invalid", "/a", "earlier pointer"),
|
|
],
|
|
artifact="scene",
|
|
)
|
|
assert [(issue.code, issue.path, issue.message) for issue in report.issues] == [
|
|
("bounds_invalid", "/a", "earlier pointer"),
|
|
("bounds_invalid", "/z", "a message"),
|
|
("bounds_invalid", "/z", "z message"),
|
|
("transform_invalid", "/z", "later registered rule"),
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="unregistered"):
|
|
_report([_issue("not_registered", "", "no authority")], artifact="scene")
|
|
with pytest.raises(ValueError, match="registered phase"):
|
|
_report(
|
|
[_issue("bounds_invalid", "", "wrong phase", "package")],
|
|
artifact="scene",
|
|
)
|
|
with pytest.raises(ValueError, match="does not apply"):
|
|
_report(
|
|
[_issue("presentation_reference_invalid", "", "wrong artifact")],
|
|
artifact="scene",
|
|
)
|
|
with pytest.raises(ValueError, match="root pointer"):
|
|
_report(
|
|
[_issue("duplicate_json_key", "/value", "wrong pointer", "parse")],
|
|
artifact="scene",
|
|
)
|
|
|
|
|
|
def test_valid_tetrahedron_scene_package_and_canonical_zip_round_trip():
|
|
scene, scene_bytes, entity_asset, entity_bytes, blobs = _build_scene_package()
|
|
|
|
assert validate_scene_manifest(scene).valid
|
|
assert validate_scene_manifest(scene_bytes).valid
|
|
assert validate_entity_asset(entity_asset).valid
|
|
assert validate_entity_asset(entity_bytes).valid
|
|
assert validate_scene_package(scene_bytes, blobs).valid
|
|
assert {preflight_glb(payload).kind for uri, payload in blobs.items() if uri.endswith(".glb")} == {
|
|
"triangle",
|
|
"line",
|
|
}
|
|
|
|
archive_bytes = canonical_zip_bytes({"scene.json": scene_bytes, **blobs})
|
|
archive = preflight_zip_bytes(archive_bytes)
|
|
assert archive.input_size == archive.canonical_size == len(archive_bytes)
|
|
assert not archive.used_deflate
|
|
assert dict(archive.members) == {"scene.json": scene_bytes, **blobs}
|
|
assert canonical_zip_bytes(archive.members) == archive_bytes
|
|
assert validate_scene_package(
|
|
archive.members["scene.json"],
|
|
{name: payload for name, payload in archive.members.items() if name != "scene.json"},
|
|
).valid
|
|
|
|
|
|
def test_embedded_presentation_is_canonical_and_resolves_exactly_against_scene():
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
scene, presentation, blobs = _attach_presentation(scene, blobs)
|
|
|
|
assert validate_scene_package(canonical_json_bytes(scene), blobs).valid
|
|
|
|
noncanonical = json.dumps(presentation, indent=2, sort_keys=True).encode("utf-8")
|
|
scene, blobs = _replace_embedded_presentation(scene, blobs, noncanonical)
|
|
report = validate_scene_package(canonical_json_bytes(scene), blobs)
|
|
assert report.first_error is not None
|
|
assert (report.first_error.code, report.first_error.path) == (
|
|
"noncanonical_json",
|
|
"",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutate", "code", "path"),
|
|
[
|
|
(
|
|
lambda presentation: presentation.__setitem__("presentation_id", "other"),
|
|
"source_matrix_invalid",
|
|
"/presentation/presentation.json/presentation_id",
|
|
),
|
|
(
|
|
lambda presentation: presentation.__setitem__("source_scene_id", "other"),
|
|
"source_matrix_invalid",
|
|
"/presentation/presentation.json/source_scene_id",
|
|
),
|
|
(
|
|
lambda presentation: presentation["node_overrides"][0].__setitem__(
|
|
"node_id", "instance/missing"
|
|
),
|
|
"source_matrix_invalid",
|
|
"/presentation/presentation.json/node_overrides/0/node_id",
|
|
),
|
|
(
|
|
lambda presentation: presentation["cameras"][0].__setitem__(
|
|
"parent_node_id", "instance/missing"
|
|
),
|
|
"source_matrix_invalid",
|
|
"/presentation/presentation.json/cameras/0/parent_node_id",
|
|
),
|
|
],
|
|
)
|
|
def test_embedded_presentation_ids_and_scene_references_are_contextual(
|
|
mutate, code, path
|
|
):
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
scene, presentation, blobs = _attach_presentation(scene, blobs)
|
|
mutate(presentation)
|
|
scene, blobs = _replace_embedded_presentation(
|
|
scene, blobs, canonical_json_bytes(presentation)
|
|
)
|
|
|
|
report = validate_scene_package(canonical_json_bytes(scene), blobs)
|
|
|
|
assert any(issue.code == code and issue.path == path for issue in report.issues)
|
|
|
|
|
|
def test_embedded_presentation_appearance_and_camera_resolution_is_exact():
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
scene, _presentation, blobs = _attach_presentation(scene, blobs)
|
|
presentation_appearance = next(
|
|
appearance
|
|
for appearance in scene["appearances"]
|
|
if isinstance(appearance["source"], dict)
|
|
)
|
|
presentation_appearance["source"]["appearance_name"] = "Other"
|
|
draft = dict(presentation_appearance)
|
|
draft.pop("appearance_id")
|
|
new_id = "appearance/evaluated/" + hashlib.sha256(
|
|
canonical_json_bytes(draft)
|
|
).hexdigest()
|
|
presentation_appearance["appearance_id"] = new_id
|
|
scene["nodes"][0]["appearance_override_id"] = new_id
|
|
scene["appearances"].sort(key=lambda appearance: appearance["appearance_id"].encode("utf-8"))
|
|
scene["cameras"] = []
|
|
scene = with_scene_revision(scene)
|
|
|
|
report = validate_scene_package(canonical_json_bytes(scene), blobs)
|
|
|
|
appearance_index = scene["appearances"].index(presentation_appearance)
|
|
assert any(
|
|
issue.code == "source_matrix_invalid"
|
|
and issue.path == f"/appearances/{appearance_index}/source/appearance_name"
|
|
for issue in report.issues
|
|
)
|
|
assert any(
|
|
issue.code == "source_matrix_invalid"
|
|
and issue.path == "/presentation/presentation.json/cameras/0"
|
|
for issue in report.issues
|
|
)
|
|
|
|
|
|
def test_product_material_appearance_provenance_must_match_a_part_root():
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, _blobs = _build_scene_package()
|
|
appearance = scene["appearances"][0]
|
|
appearance["source"] = {
|
|
"kind": "product_material",
|
|
"material_id": "steel",
|
|
"root_id": "other",
|
|
}
|
|
draft = dict(appearance)
|
|
draft.pop("appearance_id")
|
|
appearance_id = "appearance/evaluated/" + hashlib.sha256(
|
|
canonical_json_bytes(draft)
|
|
).hexdigest()
|
|
appearance["appearance_id"] = appearance_id
|
|
scene["definitions"][0]["appearance_id"] = appearance_id
|
|
scene = with_scene_revision(scene)
|
|
|
|
report = validate_scene_manifest(scene)
|
|
|
|
assert any(
|
|
issue.code == "source_matrix_invalid"
|
|
and issue.path == "/appearances/0/source/root_id"
|
|
for issue in report.issues
|
|
)
|
|
|
|
|
|
def test_package_rejects_conflicting_records_for_one_uri_and_dispatches_by_role():
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
edge_uri = scene["edge_assets"][0]["uri"]
|
|
scene["edge_assets"][0]["uri"] = scene["geometry_assets"][0]["uri"]
|
|
scene = with_scene_revision(scene)
|
|
blobs = dict(blobs)
|
|
blobs.pop(edge_uri)
|
|
|
|
report = validate_scene_package(canonical_json_bytes(scene), blobs)
|
|
|
|
assert any(issue.code == "package_member_set_invalid" for issue in report.issues)
|
|
assert any(
|
|
issue.code == "source_matrix_invalid" and issue.path == "/edge_assets/0/uri"
|
|
for issue in report.issues
|
|
)
|
|
|
|
|
|
def test_package_budget_formula_and_first_error_use_declarative_limits():
|
|
totals = _compute_package_budget_totals(
|
|
scene_json_bytes=7,
|
|
glb_decoded_buffer_bytes=11,
|
|
entity_json_bytes=13,
|
|
other_immutable_json_bytes=17,
|
|
entity_count=23,
|
|
entity_vertex_count=19,
|
|
triangle_vertex_count=29,
|
|
triangle_count=31,
|
|
line_vertex_count=37,
|
|
line_segment_count=41,
|
|
)
|
|
assert totals == {
|
|
"static_decoded_buffer_bytes": 656,
|
|
"entity_count": 23,
|
|
"triangle_vertex_total": 29,
|
|
"triangle_total": 31,
|
|
"line_vertex_total": 37,
|
|
"line_segment_total": 41,
|
|
}
|
|
exact = SimpleNamespace(
|
|
static_decoded_buffer_bytes=656,
|
|
entities_total=23,
|
|
triangle_vertices_total=29,
|
|
triangles_total=31,
|
|
line_vertices_total=37,
|
|
line_segments_total=41,
|
|
)
|
|
assert not _package_budget_issues(totals, limits=exact)
|
|
|
|
over_every_limit = SimpleNamespace(
|
|
static_decoded_buffer_bytes=655,
|
|
entities_total=22,
|
|
triangle_vertices_total=28,
|
|
triangles_total=30,
|
|
line_vertices_total=36,
|
|
line_segments_total=40,
|
|
)
|
|
report = _report(
|
|
_package_budget_issues(totals, limits=over_every_limit), artifact="package"
|
|
)
|
|
assert report.first_error is not None
|
|
assert (
|
|
report.first_error.code,
|
|
report.first_error.path,
|
|
report.first_error.message,
|
|
) == (
|
|
"resource_limit_exceeded",
|
|
"",
|
|
"total line GLB vertex count exceeds resource limit",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("value", [None, [], {"definitions": 1}, {"nodes": 1}, {"entities": 1}])
|
|
def test_structurally_invalid_values_return_reports_instead_of_raising(value):
|
|
assert not validate_scene_manifest(value).valid
|
|
assert not validate_entity_asset(value).valid
|
|
|
|
|
|
def test_asset_bearing_scene_requires_a_root_occurrence():
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, _blobs = _build_scene_package()
|
|
scene["nodes"] = []
|
|
scene = with_scene_revision(scene)
|
|
|
|
report = validate_scene_manifest(scene)
|
|
|
|
assert "hierarchy_invalid" in _codes(report)
|
|
|
|
|
|
def test_package_rejects_manifest_bounds_that_differ_from_glb():
|
|
scene, _scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
scene["geometry_assets"][0]["scene_local_bounds"]["max"][0] = 999
|
|
scene = with_scene_revision(scene)
|
|
|
|
report = validate_scene_package(canonical_json_bytes(scene), blobs)
|
|
|
|
assert "bounds_invalid" in _codes(report)
|
|
|
|
|
|
def test_glb_rejects_json_strings_and_booleans_in_numeric_fields():
|
|
_scene, _scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
triangle = next(payload for uri, payload in blobs.items() if uri.startswith("geometry/"))
|
|
string_bounds = _replace_glb_json(
|
|
triangle, lambda document: document["accessors"][0]["min"].__setitem__(0, "0")
|
|
)
|
|
boolean_scene = _replace_glb_json(triangle, lambda document: document.__setitem__("scene", False))
|
|
|
|
with pytest.raises(ValueError, match="three-number"):
|
|
preflight_glb(string_bounds)
|
|
with pytest.raises(ValueError, match="non-negative safe integer"):
|
|
preflight_glb(boolean_scene)
|
|
|
|
|
|
def test_entity_asset_rejects_sparse_ids_and_non_unit_analytic_directions():
|
|
_scene, _scene_bytes, entity_asset, _entity_bytes, _blobs = _build_scene_package()
|
|
sparse = deepcopy(entity_asset)
|
|
sparse["entities"][0]["entity_id"] = "entity/edge/99"
|
|
non_unit = deepcopy(entity_asset)
|
|
edge = next(entity for entity in non_unit["entities"] if entity["kind"] == "edge")
|
|
edge["geometry"]["direction"] = [2, 0, 0]
|
|
|
|
assert "entity_topology_invalid" in _codes(validate_entity_asset(sparse))
|
|
assert "analytic_geometry_invalid" in _codes(validate_entity_asset(non_unit))
|
|
|
|
|
|
def test_package_enforces_contextual_entity_binding_status():
|
|
scene, _scene_bytes, entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
face = next(entity for entity in entity_asset["entities"] if entity["kind"] == "face")
|
|
face["connector_binding_status"] = "supported"
|
|
entity_bytes = canonical_json_bytes(entity_asset)
|
|
old_uri = scene["entity_assets"][0]["uri"]
|
|
entity_hash = "sha256:" + hashlib.sha256(entity_bytes).hexdigest()
|
|
entity_uri = f"entities/sha256-{entity_hash.removeprefix('sha256:')}.json"
|
|
scene["entity_assets"][0].update(
|
|
{
|
|
"byte_length": len(entity_bytes),
|
|
"content_hash": entity_hash,
|
|
"entity_asset_id": entity_hash,
|
|
"uri": entity_uri,
|
|
}
|
|
)
|
|
scene["definitions"][0]["entity_asset_id"] = entity_hash
|
|
scene = with_scene_revision(scene)
|
|
blobs = {uri: payload for uri, payload in blobs.items() if uri != old_uri}
|
|
blobs[entity_uri] = entity_bytes
|
|
|
|
report = validate_scene_package(canonical_json_bytes(scene), blobs)
|
|
|
|
assert "connector_invalid" in _codes(report)
|
|
|
|
|
|
def test_deep_python_value_returns_a_budget_report_without_recursion_error():
|
|
value = {}
|
|
current = value
|
|
for _index in range(1000):
|
|
child = {}
|
|
current["x"] = child
|
|
current = child
|
|
|
|
report = validate_scene_manifest(value)
|
|
|
|
assert report.first_error is not None
|
|
assert report.first_error.code == "resource_limit_exceeded"
|
|
|
|
|
|
def test_public_validator_resource_overflows_remain_budget_issues():
|
|
scene, scene_bytes, _entity_asset, _entity_bytes, _blobs = _build_scene_package()
|
|
collection_report = validate_scene_manifest(
|
|
scene, limits=replace(BASE_LIMITS, definitions=0)
|
|
)
|
|
serialized_report = validate_scene_manifest(
|
|
scene, limits=replace(BASE_LIMITS, scene_json_bytes=len(scene_bytes) - 1)
|
|
)
|
|
|
|
assert collection_report.first_error is not None
|
|
assert (
|
|
collection_report.first_error.code,
|
|
collection_report.first_error.phase,
|
|
collection_report.first_error.path,
|
|
) == ("resource_limit_exceeded", "budget", "/definitions")
|
|
assert serialized_report.first_error is not None
|
|
assert (
|
|
serialized_report.first_error.code,
|
|
serialized_report.first_error.phase,
|
|
serialized_report.first_error.path,
|
|
) == ("resource_limit_exceeded", "budget", "")
|
|
|
|
|
|
def test_zip_preflight_rejects_crc_mutation_and_trailing_data():
|
|
_scene, scene_bytes, _entity_asset, _entity_bytes, blobs = _build_scene_package()
|
|
archive = canonical_zip_bytes({"scene.json": scene_bytes, **blobs})
|
|
corrupted = bytearray(archive)
|
|
first_payload = 30 + struct.unpack_from("<H", archive, 26)[0]
|
|
corrupted[first_payload] ^= 1
|
|
|
|
with pytest.raises(ValueError, match="CRC"):
|
|
preflight_zip_bytes(corrupted)
|
|
with pytest.raises(ValueError, match="EOCD"):
|
|
preflight_zip_bytes(archive + b"x")
|
|
|
|
|
|
def test_zip_preflight_checks_aggregate_ratio_boundary_before_inflate(monkeypatch):
|
|
compressed = b"\0"
|
|
template = _declared_deflate_zip(compressed, 0)
|
|
exact_size = len(template) * BASE_LIMITS.compression_ratio
|
|
|
|
def fail_inflate(*_args, **_kwargs):
|
|
raise AssertionError("inflate must not run before ratio preflight")
|
|
|
|
monkeypatch.setattr(scene_archive.zlib, "decompressobj", fail_inflate)
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'^member compression ratio exceeds limit: "scene\.json"$',
|
|
):
|
|
preflight_zip_bytes(_declared_deflate_zip(compressed, exact_size))
|
|
with pytest.raises(ValueError, match="^aggregate compression ratio exceeds limit$"):
|
|
preflight_zip_bytes(_declared_deflate_zip(compressed, exact_size + 1))
|
|
with pytest.raises(ValueError, match="^ZIP central/local header mismatch$"):
|
|
preflight_zip_bytes(
|
|
_declared_deflate_zip(
|
|
compressed,
|
|
exact_size + 1,
|
|
local_uncompressed_size=exact_size,
|
|
)
|
|
)
|
|
|
|
|
|
def test_zip_preflight_zero_compressed_size_uses_one_for_member_ratio():
|
|
with pytest.raises(ValueError, match=r'^invalid deflate stream: "scene\.json"$'):
|
|
preflight_zip_bytes(
|
|
_declared_deflate_zip(b"", BASE_LIMITS.compression_ratio)
|
|
)
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'^member compression ratio exceeds limit: "scene\.json"$',
|
|
):
|
|
preflight_zip_bytes(
|
|
_declared_deflate_zip(b"", BASE_LIMITS.compression_ratio + 1)
|
|
)
|
|
|
|
|
|
def test_python_matches_all_shared_manifest_and_entity_cases():
|
|
corpus = _load_shared_corpus()
|
|
validators = (
|
|
(corpus["manifest_cases"], validate_scene_manifest),
|
|
(corpus["entity_cases"], validate_entity_asset),
|
|
(corpus["presentation_cases"], validate_presentation),
|
|
(corpus["connector_binding_cases"], validate_connector_binding),
|
|
(corpus["normalized_product_cases"], validate_normalized_product),
|
|
)
|
|
for cases, validator in validators:
|
|
for case in cases:
|
|
report = validator(_decode_base64(case["payload_base64"]))
|
|
assert report.valid is case["valid"], case["name"]
|
|
if case["expected"] is not None:
|
|
assert report.first_error is not None
|
|
assert {
|
|
"code": report.first_error.code,
|
|
"path": report.first_error.path,
|
|
} == case["expected"], case["name"]
|
|
|
|
|
|
def test_shared_positive_case_names_cannot_drift_to_negative_expectations():
|
|
corpus = _load_shared_corpus()
|
|
positive_names = []
|
|
for section_name, cases in corpus.items():
|
|
if not isinstance(cases, list):
|
|
continue
|
|
for case in cases:
|
|
if not isinstance(case, dict):
|
|
continue
|
|
name = case.get("name")
|
|
if not isinstance(name, str) or not (
|
|
name.startswith("valid_") or name == "nullable_fields_nonnull"
|
|
):
|
|
continue
|
|
positive_names.append(f"{section_name}:{name}")
|
|
assert case["valid"] is True, positive_names[-1]
|
|
if "expected" in case:
|
|
assert case["expected"] is None, positive_names[-1]
|
|
if "error" in case:
|
|
assert case["error"] is None, positive_names[-1]
|
|
assert len(positive_names) == 30
|
|
|
|
|
|
def test_accepted_package_fixtures_cover_every_connector_binding_status():
|
|
corpus = _load_shared_corpus()
|
|
statuses = set()
|
|
for case in (*corpus["package_cases"], *corpus["scene_shape_cases"]):
|
|
if not case["valid"]:
|
|
continue
|
|
for uri, payload in corpus_blobs(corpus, case).items():
|
|
if not uri.startswith("entities/"):
|
|
continue
|
|
sidecar = parse_canonical_json(payload)
|
|
statuses.update(
|
|
entity["connector_binding_status"]
|
|
for entity in sidecar["entities"]
|
|
)
|
|
assert statuses == {
|
|
"not_applicable",
|
|
"owner_not_part",
|
|
"source_not_model",
|
|
"frame_undefined",
|
|
"selector_ambiguous",
|
|
"selector_unstable",
|
|
"supported",
|
|
}
|
|
|
|
|
|
def test_python_matches_all_shared_package_cases():
|
|
corpus = _load_shared_corpus()
|
|
precedence_statuses = {
|
|
"solid_status_precedence": "not_applicable",
|
|
"owner_not_part_status_precedence": "owner_not_part",
|
|
"source_not_model_status_precedence": "source_not_model",
|
|
"frame_undefined_status_precedence": "frame_undefined",
|
|
}
|
|
for case in corpus["package_cases"]:
|
|
blobs = corpus_blobs(corpus, case)
|
|
report = validate_scene_package(_decode_base64(case["manifest_base64"]), blobs)
|
|
assert report.valid is case["valid"], case["name"]
|
|
if case["expected"] is not None:
|
|
assert report.first_error is not None
|
|
assert {
|
|
"code": report.first_error.code,
|
|
"path": report.first_error.path,
|
|
} == case["expected"], case["name"]
|
|
expected_status = precedence_statuses.get(case["name"])
|
|
if expected_status is not None:
|
|
assert report.first_error is not None
|
|
assert report.first_error.message == (
|
|
f"connector binding status must be {expected_status}"
|
|
)
|
|
|
|
|
|
def test_python_replays_shared_scene_field_matrix():
|
|
corpus = _load_shared_corpus()
|
|
scene = parse_canonical_json(_decode_base64(corpus["artifacts"]["scene"]["base64"]))
|
|
for case in corpus["scene_field_cases"]:
|
|
value = apply_scene_field_case(scene, case)
|
|
report = validate_scene_manifest(canonical_json_bytes(value))
|
|
assert report.valid is case["valid"], case["name"]
|
|
assert _first_report_issue(report) == case["expected"], case["name"]
|
|
|
|
|
|
def test_python_characterizes_shared_scene_shapes_and_asset_reuse():
|
|
corpus = _load_shared_corpus()
|
|
for case in corpus["scene_shape_cases"]:
|
|
manifest = _decode_base64(case["manifest_base64"])
|
|
scene = parse_canonical_json(manifest)
|
|
report = validate_scene_package(manifest, corpus_blobs(corpus, case))
|
|
assert report.valid is case["valid"], case["name"]
|
|
assert _first_report_issue(report) == case["expected"], case["name"]
|
|
assert canonical_json_bytes(scene) == manifest, case["name"]
|
|
assert scene_shape_facts(scene) == case["expected_shape"], case["name"]
|
|
|
|
definitions = {item["definition_id"]: item for item in scene["definitions"]}
|
|
for definition_id, count in case["expected_shape"][
|
|
"definition_occurrence_counts"
|
|
].items():
|
|
if count > 1:
|
|
occurrences = [
|
|
node for node in scene["nodes"] if node["definition_id"] == definition_id
|
|
]
|
|
assert len(occurrences) == count
|
|
assert len({node["definition_id"] for node in occurrences}) == 1
|
|
assert definitions[definition_id]["geometry_asset_id"] is not None
|
|
|
|
|
|
def test_python_matches_shared_two_pass_revision_vectors():
|
|
for vector in _load_shared_corpus()["revision_vectors"]:
|
|
draft_bytes = _decode_base64(vector["draft_base64"])
|
|
draft = parse_canonical_json(draft_bytes)
|
|
scene_bytes = _decode_base64(vector["canonical_base64"])
|
|
scene = with_scene_revision(draft)
|
|
|
|
assert canonical_json_bytes(draft) == draft_bytes, vector["name"]
|
|
assert scene["revision"] == vector["revision"], vector["name"]
|
|
assert canonical_json_bytes(scene) == scene_bytes, vector["name"]
|
|
assert "sha256:" + hashlib.sha256(scene_bytes).hexdigest() == vector["sha256"]
|
|
|
|
|
|
def test_python_matches_all_shared_glb_and_zip_cases():
|
|
corpus = _load_shared_corpus()
|
|
for case in corpus["glb_cases"]:
|
|
payload = _decode_base64(case["payload_base64"])
|
|
if case["valid"]:
|
|
assert preflight_glb(payload, expected_kind=case["expected_kind"]).kind == case["kind"]
|
|
else:
|
|
with pytest.raises(ValueError, match="^" + re.escape(case["error"]) + "$"):
|
|
preflight_glb(payload, expected_kind=case["expected_kind"])
|
|
for case in corpus["zip_cases"]:
|
|
payload = _decode_base64(case["payload_base64"])
|
|
if case["valid"]:
|
|
assert preflight_zip_bytes(payload).used_deflate is case["used_deflate"]
|
|
else:
|
|
with pytest.raises(ValueError, match="^" + re.escape(case["error"]) + "$"):
|
|
preflight_zip_bytes(payload)
|
|
|
|
|
|
def test_python_matches_shared_numeric_profile_vectors():
|
|
for vector in _load_shared_corpus()["numeric_vectors"]:
|
|
try:
|
|
if vector["operation"] == "f32":
|
|
actual = f"{profile_f32_bits(_f64_from_hex(vector['input_bits'][0])):08x}"
|
|
elif vector["operation"] == "cross":
|
|
left, right = (
|
|
tuple(_f64_from_hex(component) for component in value)
|
|
for value in vector["input_bits"]
|
|
)
|
|
actual = [_f64_hex(component) for component in profile_cross(left, right)]
|
|
elif vector["operation"] == "normalize":
|
|
value = tuple(
|
|
_f64_from_hex(component) for component in vector["input_bits"][0]
|
|
)
|
|
actual = [
|
|
f"{profile_f32_bits(component):08x}"
|
|
for component in profile_normalize(value)
|
|
]
|
|
else:
|
|
raise AssertionError(vector["operation"])
|
|
except ValueError as exc:
|
|
assert not vector["valid"], vector["name"]
|
|
assert str(exc) == vector["error"], vector["name"]
|
|
else:
|
|
assert vector["valid"], vector["name"]
|
|
assert actual == vector["expected_bits"], vector["name"]
|
|
|
|
|
|
def test_python_matches_shared_resource_boundaries():
|
|
corpus = _load_shared_corpus()
|
|
assert asdict(BASE_LIMITS) == corpus["resource_limits"]
|
|
valid_scene = parse_canonical_json(
|
|
_decode_base64(corpus["artifacts"]["scene"]["base64"])
|
|
)
|
|
|
|
for case in corpus["resource_cases"]:
|
|
operation = case["operation"]
|
|
parameters = case["parameters"]
|
|
limits = replace(BASE_LIMITS, **parameters.get("limits", {}))
|
|
try:
|
|
if operation == "input_archive_size":
|
|
preflight_input_archive_size(parameters["size"], limits=limits)
|
|
valid, error, first = True, None, None
|
|
elif operation in {"archive_member_sizes", "archive_member_count"}:
|
|
if operation == "archive_member_sizes":
|
|
sizes = parameters["sizes"]
|
|
else:
|
|
sizes = {"scene.json": 0}
|
|
sizes.update(
|
|
{
|
|
f"x/{index:05d}": 0
|
|
for index in range(parameters["count"] - 1)
|
|
}
|
|
)
|
|
preflight_archive_member_sizes(sizes, limits=limits)
|
|
valid, error, first = True, None, None
|
|
elif operation in {
|
|
"aggregate_compression_ratio",
|
|
"member_compression_ratio",
|
|
}:
|
|
callback = (
|
|
preflight_aggregate_compression_ratio
|
|
if operation == "aggregate_compression_ratio"
|
|
else preflight_member_compression_ratio
|
|
)
|
|
callback(
|
|
parameters["uncompressed_size"],
|
|
parameters["compressed_size"],
|
|
limits=limits,
|
|
)
|
|
valid, error, first = True, None, None
|
|
elif operation == "json_depth":
|
|
value = {}
|
|
current = value
|
|
for _index in range(parameters["depth"]):
|
|
current["x"] = {}
|
|
current = current["x"]
|
|
issues = json_resource_issues(value, limits=limits)
|
|
valid = not issues
|
|
error = None
|
|
first = (
|
|
None
|
|
if not issues
|
|
else {"code": issues[0].code, "path": issues[0].path}
|
|
)
|
|
elif operation == "json_domain":
|
|
text = parameters["text"]
|
|
kind = parameters["kind"]
|
|
field = parameters.get("field")
|
|
if kind == "value":
|
|
value = {"value": text}
|
|
elif kind == "object_key":
|
|
value = {text: 0}
|
|
elif kind == "metadata_key":
|
|
value = {"metadata": {text: 0}}
|
|
elif kind == "sdk_metadata_key":
|
|
value = {"sdk_metadata": {text: 0}}
|
|
elif kind == "identifier":
|
|
value = {field or "node_id": text}
|
|
elif kind == "identifier_array":
|
|
value = {field or "component_path": [text]}
|
|
elif kind == "uri":
|
|
value = {"uri": text}
|
|
else:
|
|
raise AssertionError(kind)
|
|
issues = json_resource_issues(value, limits=limits)
|
|
valid = not issues
|
|
error = None
|
|
first = (
|
|
None
|
|
if not issues
|
|
else {"code": issues[0].code, "path": issues[0].path}
|
|
)
|
|
elif operation == "resource_count":
|
|
count = parameters["count"]
|
|
kind = parameters["kind"]
|
|
field = parameters.get("field")
|
|
if kind == "collection":
|
|
value = {field: [None] * count}
|
|
elif kind == "hierarchy":
|
|
value = {
|
|
"nodes": [
|
|
{"source": {"component_path": ["x"] * count}}
|
|
]
|
|
}
|
|
elif kind == "forwarded":
|
|
value = {
|
|
"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)
|
|
]
|
|
}
|
|
else:
|
|
raise AssertionError(kind)
|
|
issues = resource_count_issues(
|
|
value, parameters["artifact"], limits=limits
|
|
)
|
|
valid = not issues
|
|
error = None
|
|
first = (
|
|
None
|
|
if not issues
|
|
else {"code": issues[0].code, "path": issues[0].path}
|
|
)
|
|
elif operation == "glb_counts":
|
|
preflight_glb_counts(
|
|
parameters["kind"],
|
|
parameters["vertex_count"],
|
|
parameters["index_count"],
|
|
limits=limits,
|
|
)
|
|
valid, error, first = True, None, None
|
|
elif operation == "package_budget":
|
|
contributions = parameters["contributions"]
|
|
totals = _compute_package_budget_totals(**contributions)
|
|
result = _report(
|
|
_package_budget_issues(totals, limits=limits),
|
|
artifact="package",
|
|
)
|
|
valid, error, first = result.valid, None, _first_report_issue(result)
|
|
elif operation == "scene_geometry_byte_length":
|
|
value = deepcopy(valid_scene)
|
|
byte_length = int(parameters["value"])
|
|
value["geometry_assets"][0]["byte_length"] = byte_length
|
|
if byte_length <= 9_007_199_254_740_991:
|
|
value = with_scene_revision(value)
|
|
result = validate_scene_manifest(value)
|
|
valid, error, first = result.valid, None, _first_report_issue(result)
|
|
elif operation == "scene_compile_option":
|
|
value = deepcopy(valid_scene)
|
|
field, number = parameters["field"], parameters["value"]
|
|
value["compile_options"][field] = number
|
|
value["geometry_assets"][0]["tessellation"][field] = number
|
|
if field == "linear_tolerance":
|
|
value["edge_assets"][0]["tessellation"][field] = number
|
|
result = validate_scene_manifest(with_scene_revision(value))
|
|
valid, error, first = result.valid, None, _first_report_issue(result)
|
|
else:
|
|
raise AssertionError(operation)
|
|
except ValueError as exc:
|
|
valid, error, first = False, str(exc), None
|
|
|
|
assert valid == case["valid"], case["name"]
|
|
if "error" in case:
|
|
assert error == case["error"], case["name"]
|
|
if case.get("expected") is not None:
|
|
assert first == case["expected"], case["name"]
|
|
|
|
|
|
def test_shared_exact_byte_artifacts_and_jcs_vectors():
|
|
corpus = _load_shared_corpus()
|
|
for artifact in corpus["artifacts"].values():
|
|
payload = _decode_base64(artifact["base64"])
|
|
assert "sha256:" + hashlib.sha256(payload).hexdigest() == artifact["sha256"]
|
|
for vector in corpus["jcs_vectors"]:
|
|
payload = canonical_json_bytes(vector["input"])
|
|
assert payload == _decode_base64(vector["canonical_base64"])
|
|
assert "sha256:" + hashlib.sha256(payload).hexdigest() == vector["sha256"]
|
|
archive = preflight_zip_bytes(_decode_base64(corpus["artifacts"]["canonical_zip"]["base64"]))
|
|
assert canonical_zip_bytes(archive.members) == _decode_base64(
|
|
corpus["artifacts"]["canonical_zip"]["base64"]
|
|
)
|
|
|
|
|
|
def _apply_schema_mutations(base, mutations):
|
|
value = deepcopy(base)
|
|
for mutation in mutations:
|
|
current = value
|
|
for part in mutation["path"][:-1]:
|
|
current = current[part]
|
|
field = mutation["path"][-1]
|
|
if mutation["operation"] == "delete":
|
|
del current[field]
|
|
else:
|
|
current[field] = deepcopy(mutation["value"])
|
|
return value
|
|
|
|
|
|
def test_python_replays_all_closed_schema_record_field_matrices():
|
|
corpus = _load_shared_corpus()
|
|
schemas = {
|
|
artifact: parse_strict_json(load_contract_artifact(path))
|
|
for artifact, path in SCHEMA_FILES.items()
|
|
}
|
|
for matrix in corpus["schema_field_matrices"]:
|
|
root = schemas[matrix["artifact"]]
|
|
pointer = matrix["schema_pointer"]
|
|
schema = (
|
|
root
|
|
if pointer == "#"
|
|
else {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$ref": pointer,
|
|
"$defs": root["$defs"],
|
|
}
|
|
)
|
|
validator = Draft202012Validator(schema)
|
|
for case in matrix["cases"]:
|
|
value = _apply_schema_mutations(matrix["base"], case["mutations"])
|
|
assert validator.is_valid(value) is case["valid"], (
|
|
matrix["artifact"],
|
|
pointer,
|
|
matrix["variant"],
|
|
case["name"],
|
|
)
|
|
|
|
|
|
def _valid_document_payloads():
|
|
corpus = _load_shared_corpus()
|
|
return (
|
|
("scene", SceneDocument, _decode_base64(corpus["artifacts"]["scene"]["base64"])),
|
|
("entities", EntityDocument, _decode_base64(corpus["artifacts"]["entities"]["base64"])),
|
|
(
|
|
"presentation",
|
|
PresentationDocument,
|
|
_decode_base64(next(case for case in corpus["presentation_cases"] if case["valid"])["payload_base64"]),
|
|
),
|
|
(
|
|
"connector_binding",
|
|
ConnectorBindingDocument,
|
|
_decode_base64(next(case for case in corpus["connector_binding_cases"] if case["valid"])["payload_base64"]),
|
|
),
|
|
(
|
|
"normalized_product",
|
|
NormalizedProductDocument,
|
|
_decode_base64(next(case for case in corpus["normalized_product_cases"] if case["valid"])["payload_base64"]),
|
|
),
|
|
)
|
|
|
|
|
|
def _assert_deeply_immutable(value):
|
|
if isinstance(value, Mapping):
|
|
assert isinstance(value, MappingProxyType)
|
|
for child in value.values():
|
|
_assert_deeply_immutable(child)
|
|
elif isinstance(value, tuple):
|
|
for child in value:
|
|
_assert_deeply_immutable(child)
|
|
else:
|
|
assert not isinstance(value, (dict, list))
|
|
|
|
|
|
def _nested_container(value):
|
|
for child in value.values():
|
|
if isinstance(child, (Mapping, tuple, dict, list)):
|
|
return child
|
|
raise AssertionError("fixture has no nested container")
|
|
|
|
|
|
def test_immutable_documents_construct_parse_validate_and_round_trip_canonically():
|
|
for name, document_type, payload in _valid_document_payloads():
|
|
mutable = parse_canonical_json(payload)
|
|
constructed = document_type.from_value(mutable)
|
|
parsed = document_type.parse(payload)
|
|
|
|
assert is_dataclass(constructed), name
|
|
assert constructed.canonical_bytes == payload, name
|
|
assert parsed.canonical_bytes == payload, name
|
|
assert constructed.canonical_hash == "sha256:" + hashlib.sha256(payload).hexdigest(), name
|
|
assert constructed == parsed, name
|
|
assert bytes(constructed) == payload, name
|
|
assert document_type.from_value(constructed.value) == constructed, name
|
|
assert document_type.from_value(constructed.to_mutable()) == constructed, name
|
|
|
|
|
|
def test_immutable_documents_resist_nested_mutation_and_isolate_mutable_exports():
|
|
for name, document_type, payload in _valid_document_payloads():
|
|
source = parse_canonical_json(payload)
|
|
document = document_type.from_value(source)
|
|
expected_bytes = document.canonical_bytes
|
|
|
|
_assert_deeply_immutable(document.value)
|
|
with pytest.raises(TypeError):
|
|
document.value["mutation"] = True
|
|
frozen_nested = _nested_container(document.value)
|
|
with pytest.raises((TypeError, AttributeError)):
|
|
if isinstance(frozen_nested, Mapping):
|
|
frozen_nested["mutation"] = True
|
|
else:
|
|
frozen_nested.append(None)
|
|
with pytest.raises(FrozenInstanceError):
|
|
document._canonical_bytes = b"mutated"
|
|
|
|
source["mutation"] = True
|
|
first_copy = document.to_mutable()
|
|
second_copy = document.to_mutable()
|
|
mutable_nested = _nested_container(first_copy)
|
|
if isinstance(mutable_nested, dict):
|
|
mutable_nested["mutation"] = True
|
|
else:
|
|
mutable_nested.append(None)
|
|
first_copy["mutation"] = True
|
|
|
|
assert "mutation" not in document.value, name
|
|
assert "mutation" not in second_copy, name
|
|
assert first_copy != second_copy, name
|
|
assert document.canonical_bytes == expected_bytes, name
|
|
|
|
|
|
def test_immutable_document_constructors_reject_invalid_values_and_stale_revisions():
|
|
for _name, document_type, _payload in _valid_document_payloads():
|
|
with pytest.raises(SceneContractError):
|
|
document_type.from_value({})
|
|
|
|
with pytest.raises(ValueError, match="canonical"):
|
|
SceneDocument.parse(b'{ "schema_version": "1.0" }')
|
|
|
|
corpus = _load_shared_corpus()
|
|
scene = parse_canonical_json(_decode_base64(corpus["artifacts"]["scene"]["base64"]))
|
|
scene["revision"] = "sha256:" + "0" * 64
|
|
with pytest.raises(SceneContractError) as error:
|
|
SceneDocument.from_value(scene)
|
|
assert error.value.report.first_error.code == "revision_mismatch"
|