diff --git a/pyproject.toml b/pyproject.toml index 6bcf4482..a412b9ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ dev = [ "ruff>=0.6.0", "black>=24.3.0", "mypy>=1.8.0", + # Validates the shared canonical `2.0` layout schema (drift guard with optiverse-web). + "jsonschema>=4.0", ] [project.urls] diff --git a/schema/optiverse-2.0.schema.json b/schema/optiverse-2.0.schema.json new file mode 100644 index 00000000..2141d31c --- /dev/null +++ b/schema/optiverse-2.0.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://optiverse.dev/schema/optiverse-2.0.schema.json", + "title": "Optiverse Layout 2.0", + "description": "The canonical Optiverse project layout format shared by the desktop app and the web project-zip interchange. This schema is the drift guard: both repos validate the same golden fixtures against it in CI so the two apps cannot silently diverge.", + "type": "object", + "required": ["version", "items"], + "properties": { + "version": { "const": "2.0" }, + "items": { "type": "array", "items": { "$ref": "#/$defs/item" } }, + "rulers": { "type": "array", "items": { "$ref": "#/$defs/ruler" } }, + "texts": { "type": "array", "items": { "$ref": "#/$defs/text" } }, + "angle_measures": { + "description": "Optiverse-web interchange EXTENSION (not emitted by the desktop app, which does not persist angle measurements). Carried so web angle annotations survive a web -> zip -> web round-trip; the desktop ignores this key.", + "type": "array", + "items": { "$ref": "#/$defs/angle_measure" } + }, + "rectangles": { "type": "array" }, + "path_measures": { "type": "array" }, + "layer_state": { "$ref": "#/$defs/layer_state" } + }, + "$defs": { + "item": { + "type": "object", + "required": ["_type"], + "properties": { + "_type": { "enum": ["component", "source"] }, + "x_mm": { "type": "number" }, + "y_mm": { "type": "number" }, + "angle_deg": { "type": "number" }, + "item_uuid": { "type": "string" }, + "z_value": { "type": "number" } + }, + "allOf": [ + { + "if": { "properties": { "_type": { "const": "component" } } }, + "then": { + "required": ["x_mm", "y_mm", "angle_deg", "image_path", "interfaces"], + "properties": { + "image_path": { "type": "string" }, + "object_height_mm": { "type": "number" }, + "interfaces": { "type": "array", "items": { "$ref": "#/$defs/interface" } } + } + } + }, + { + "if": { "properties": { "_type": { "const": "source" } } }, + "then": { + "required": ["x_mm", "y_mm", "angle_deg"], + "properties": { + "wavelength_nm": { "type": "number" }, + "n_rays": { "type": "number" } + } + } + } + ] + }, + "interface": { + "type": "object", + "required": ["element_type", "x1_mm", "y1_mm", "x2_mm", "y2_mm"], + "properties": { + "element_type": { + "enum": [ + "beam_block", + "lens", + "mirror", + "beam_splitter", + "dichroic", + "polarizing_interface", + "polarizer", + "waveplate", + "pbs", + "detector", + "refractive", + "faraday" + ] + }, + "x1_mm": { "type": "number" }, + "y1_mm": { "type": "number" }, + "x2_mm": { "type": "number" }, + "y2_mm": { "type": "number" }, + "name": { "type": "string" }, + "efl_mm": { "type": "number" }, + "reflectivity": { "type": "number" }, + "clear_aperture_mm": { "type": "number" }, + "split_T": { "type": "number" }, + "split_R": { "type": "number" }, + "is_polarizing": { "type": "boolean" }, + "pbs_transmission_axis_deg": { "type": "number" } + } + }, + "ruler": { + "type": "object", + "required": ["type", "points"], + "properties": { + "type": { "const": "ruler" }, + "points": { + "type": "array", + "items": { "type": "array", "items": { "type": "number" }, "minItems": 2 } + }, + "item_uuid": { "type": "string" } + } + }, + "angle_measure": { + "type": "object", + "required": ["type", "vertex", "point1", "point2"], + "properties": { + "type": { "const": "angle_measure" }, + "vertex": { "type": "array", "items": { "type": "number" }, "minItems": 2 }, + "point1": { "type": "array", "items": { "type": "number" }, "minItems": 2 }, + "point2": { "type": "array", "items": { "type": "number" }, "minItems": 2 }, + "item_uuid": { "type": "string" } + } + }, + "text": { + "type": "object", + "required": ["type", "text", "x", "y"], + "properties": { + "type": { "const": "text" }, + "text": { "type": "string" }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "point_size": { "type": "number" }, + "item_uuid": { "type": "string" } + } + }, + "layer_state": { + "type": "object", + "required": ["nodes"], + "properties": { + "version": { "type": "integer" }, + "nodes": { "type": "array", "items": { "$ref": "#/$defs/node" } } + } + }, + "node": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["group", "item"] }, + "uuid": { "type": "string" }, + "name": { "type": "string" }, + "collapsed": { "type": "boolean" }, + "children": { "type": "array", "items": { "$ref": "#/$defs/node" } } + } + } + } +} diff --git a/tests/fixtures/minimal_layout_2_0.json b/tests/fixtures/minimal_layout_2_0.json new file mode 100644 index 00000000..d1c7f223 --- /dev/null +++ b/tests/fixtures/minimal_layout_2_0.json @@ -0,0 +1,128 @@ +{ + "version": "2.0", + "items": [ + { + "_type": "source", + "x_mm": -100.0, + "y_mm": 0.0, + "angle_deg": 0.0, + "size_mm": 5.0, + "n_rays": 9, + "ray_length_mm": 600.0, + "spread_deg": 0.0, + "color_hex": "#22cc55", + "wavelength_nm": 780.0, + "polarization_type": "horizontal", + "polarization_angle_deg": 0.0, + "source_type": "ray", + "beam_waist_mm": 0.5, + "item_uuid": "src-0001", + "locked": false, + "z_value": 0.0 + }, + { + "_type": "component", + "x_mm": 0.0, + "y_mm": 0.0, + "angle_deg": 45.0, + "object_height_mm": 25.4, + "mm_per_pixel": 0.05, + "name": "Beamsplitter 50/50", + "image_path": "@component/bs_5050/images/bs_5050.png", + "reference_line_mm": [-12.7, 0.0, 12.7, 0.0], + "interfaces": [ + { + "x1_mm": -12.7, + "y1_mm": 0.0, + "x2_mm": 12.7, + "y2_mm": 0.0, + "element_type": "beam_splitter", + "name": "", + "split_T": 50.0, + "split_R": 50.0, + "is_polarizing": false + } + ], + "category": "beamsplitters", + "notes": "", + "item_uuid": "cmp-0001", + "locked": false, + "z_value": 1.0 + }, + { + "_type": "component", + "x_mm": 120.0, + "y_mm": 0.0, + "angle_deg": 90.0, + "object_height_mm": 25.4, + "mm_per_pixel": 0.05, + "name": "Mirror", + "image_path": "@component/flat_mirror/images/flat_mirror.png", + "reference_line_mm": [-12.7, 0.0, 12.7, 0.0], + "interfaces": [ + { + "x1_mm": -12.7, + "y1_mm": 0.0, + "x2_mm": 12.7, + "y2_mm": 0.0, + "element_type": "mirror", + "name": "", + "reflectivity": 100.0 + } + ], + "category": "mirrors", + "notes": "", + "item_uuid": "cmp-0002", + "locked": false, + "z_value": 2.0 + } + ], + "rulers": [ + { + "type": "ruler", + "points": [[-100.0, 0.0], [0.0, 0.0]], + "item_uuid": "rul-0001", + "z_value": 0.0 + } + ], + "texts": [ + { + "type": "text", + "text": "Input beam", + "x": -90.0, + "y": -10.0, + "color": "#0a0a28", + "point_size": 11.0, + "item_uuid": "txt-0001", + "z_value": 0.0 + } + ], + "angle_measures": [ + { + "type": "angle_measure", + "vertex": [0.0, 0.0], + "point1": [120.0, 0.0], + "point2": [0.0, 120.0], + "item_uuid": "ang-0001", + "z_value": 0.0 + } + ], + "rectangles": [], + "path_measures": [], + "layer_state": { + "version": 1, + "nodes": [ + { + "uuid": "grp-0001", + "type": "group", + "name": "Beam path", + "collapsed": false, + "children": [ + { "uuid": "src-0001", "type": "item" }, + { "uuid": "cmp-0001", "type": "item" } + ] + }, + { "uuid": "cmp-0002", "type": "item" } + ] + } +} diff --git a/tests/test_layout_schema.py b/tests/test_layout_schema.py new file mode 100644 index 00000000..a3f7b190 --- /dev/null +++ b/tests/test_layout_schema.py @@ -0,0 +1,47 @@ +"""Drift guard: the canonical `2.0` layout schema (shared with optiverse-web). + +The desktop app is the source of the `2.0` project file format. This test pins it +against the same JSON Schema + golden fixture that optiverse-web validates in its +own CI, so the two apps cannot silently diverge. Keep +``schema/optiverse-2.0.schema.json`` and ``tests/fixtures/minimal_layout_2_0.json`` +byte-identical to the optiverse-web copies. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_SCHEMA = _REPO / "schema" / "optiverse-2.0.schema.json" +_FIXTURE = _REPO / "tests" / "fixtures" / "minimal_layout_2_0.json" +_CONSTANTS = _REPO / "src" / "optiverse" / "core" / "constants.py" + + +def _file_format_version() -> str: + """Read FILE_FORMAT_VERSION straight from the (import-free) constants module. + + Loading by file path avoids dragging in the Qt-heavy package import chain, so + the drift guard runs in any environment that has the source tree. + """ + spec = importlib.util.spec_from_file_location("optiverse_constants", _CONSTANTS) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.FILE_FORMAT_VERSION + + +def test_file_format_version_matches_schema() -> None: + schema = json.loads(_SCHEMA.read_text()) + assert schema["properties"]["version"]["const"] == _file_format_version() + + +def test_golden_fixture_matches_schema() -> None: + jsonschema = pytest.importorskip("jsonschema") + jsonschema.validate( + instance=json.loads(_FIXTURE.read_text()), + schema=json.loads(_SCHEMA.read_text()), + )