ifcurl/tests/test_url.py
Bruno Postle 2de4af5f4d bcf: add bcf_viewpoint_to_ifc_url() and IfcUrl.to_string()
Implements SPECIFICATION.md §6 BCF→ifc:// direction: parse a BCF 3.0
REST viewpoint dict (camera, clips, selection/visibility) and apply it
to a base IfcUrl to produce an ifc:// URL string.

IfcUrl.to_string() serialises any IfcUrl back to a valid ifc:// URL,
enabling round-trip parse→to_string for all URL forms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:04:04 +01:00

376 lines
11 KiB
Python

"""Tests for IfcUrl.parse()."""
import pytest
from ifcurl.url import IfcUrl
# ---------------------------------------------------------------------------
# Transport inference
# ---------------------------------------------------------------------------
def test_https_transport():
url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc")
assert url.transport == "https"
assert url.host == "example.com"
assert url.user is None
def test_ssh_transport():
url = IfcUrl.parse("ifc://git@example.com/org/repo@abc123?path=model.ifc")
assert url.transport == "ssh"
assert url.host == "example.com"
assert url.user == "git"
def test_local_transport():
url = IfcUrl.parse("ifc:///home/alice/project@HEAD?path=model.ifc")
assert url.transport == "local"
assert url.host == ""
assert url.user is None
# ---------------------------------------------------------------------------
# Repo path and ref
# ---------------------------------------------------------------------------
def test_repo_path_and_branch_ref():
url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc")
assert url.repo_path == "org/repo"
assert url.ref == "heads/main"
def test_tag_ref():
url = IfcUrl.parse("ifc://example.com/org/repo@tags/v1.2?path=model.ifc")
assert url.ref == "tags/v1.2"
def test_commit_hash_ref():
url = IfcUrl.parse("ifc://example.com/org/repo@abc123def?path=model.ifc")
assert url.ref == "abc123def"
def test_head_ref():
url = IfcUrl.parse("ifc:///home/alice/project@HEAD?path=model.ifc")
assert url.ref == "HEAD"
assert url.repo_path == "/home/alice/project"
# ---------------------------------------------------------------------------
# git_ref() conversion
# ---------------------------------------------------------------------------
def test_git_ref_branch():
url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc")
assert url.git_ref() == "main"
def test_git_ref_tag():
url = IfcUrl.parse("ifc://example.com/org/repo@tags/v2.0?path=model.ifc")
assert url.git_ref() == "refs/tags/v2.0"
def test_git_ref_commit():
url = IfcUrl.parse("ifc://example.com/org/repo@abc123def?path=model.ifc")
assert url.git_ref() == "abc123def"
def test_git_ref_head():
url = IfcUrl.parse("ifc:///home/alice/repo@HEAD?path=model.ifc")
assert url.git_ref() == "HEAD"
# ---------------------------------------------------------------------------
# Query parameters
# ---------------------------------------------------------------------------
def test_path_parameter():
url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=models/building.ifc")
assert url.path == "models/building.ifc"
def test_no_path():
url = IfcUrl.parse("ifc://example.com/org/repo@heads/main")
assert url.path is None
def test_selector():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall")
assert url.selector == "IfcWall"
def test_selector_percent_encoded():
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall%2C%2BName%3D%22X%22"
)
assert url.selector == 'IfcWall,+Name="X"'
def test_camera_perspective():
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc"
"&camera=10,20,5,0,-1,0,0,0,1&fov=60"
)
assert url.camera == (10.0, 20.0, 5.0, 0.0, -1.0, 0.0, 0.0, 0.0, 1.0)
assert url.fov == 60.0
assert url.scale is None
def test_camera_orthographic():
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc"
"&camera=0,0,8,0,0,-1,0,1,0&scale=50"
)
assert url.scale == 50.0
assert url.fov is None
def test_clip_single():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&clip=0,0,3,0,0,-1")
assert url.clips == [(0.0, 0.0, 3.0, 0.0, 0.0, -1.0)]
def test_clip_multiple():
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc"
"&clip=0,0,3,0,0,-1&clip=0,0,0,0,0,1"
)
assert len(url.clips) == 2
assert url.clips[0] == (0.0, 0.0, 3.0, 0.0, 0.0, -1.0)
assert url.clips[1] == (0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
def test_visibility_default():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc")
assert url.visibility == "highlight"
def test_visibility_ghost():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&visibility=ghost")
assert url.visibility == "ghost"
def test_visibility_isolate():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&visibility=isolate")
assert url.visibility == "isolate"
# ---------------------------------------------------------------------------
# git_remote_url()
# ---------------------------------------------------------------------------
def test_git_remote_url_https():
url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=m.ifc")
assert url.git_remote_url() == "https://example.com/org/repo"
def test_git_remote_url_ssh():
url = IfcUrl.parse("ifc://git@example.com/org/repo@heads/main?path=m.ifc")
assert url.git_remote_url() == "ssh://git@example.com/org/repo"
def test_git_remote_url_local():
url = IfcUrl.parse("ifc:///home/alice/project@HEAD?path=m.ifc")
assert url.git_remote_url() == "/home/alice/project"
def test_git_remote_url_https_nonstandard_port():
url = IfcUrl.parse("ifc://localhost:3000/org/repo@heads/main?path=m.ifc")
assert url.git_remote_url() == "https://localhost:3000/org/repo"
def test_git_remote_url_ssh_nonstandard_port():
url = IfcUrl.parse("ifc://git@localhost:2222/org/repo@heads/main?path=m.ifc")
assert url.git_remote_url() == "ssh://git@localhost:2222/org/repo"
def test_host_preserves_port():
url = IfcUrl.parse("ifc://example.com:8080/org/repo@HEAD?path=m.ifc")
assert url.host == "example.com:8080"
# ---------------------------------------------------------------------------
# is_mutable_ref()
# ---------------------------------------------------------------------------
def test_mutable_head():
assert IfcUrl.parse("ifc://h/o/r@HEAD?path=m.ifc").is_mutable_ref() is True
def test_mutable_branch():
assert IfcUrl.parse("ifc://h/o/r@heads/main?path=m.ifc").is_mutable_ref() is True
def test_immutable_commit():
assert IfcUrl.parse("ifc://h/o/r@abc123def?path=m.ifc").is_mutable_ref() is False
def test_immutable_tag():
assert IfcUrl.parse("ifc://h/o/r@tags/v1.0?path=m.ifc").is_mutable_ref() is False
# ---------------------------------------------------------------------------
# Spec examples
# ---------------------------------------------------------------------------
def test_spec_example_default_view():
url = IfcUrl.parse(
"ifc://example.com/org/project@heads/main?path=models/building.ifc"
)
assert url.transport == "https"
assert url.ref == "heads/main"
assert url.path == "models/building.ifc"
assert url.selector is None
assert url.camera is None
def test_spec_example_perspective():
url = IfcUrl.parse(
"ifc://git@example.com/org/project@abc123def"
"?path=models/building.ifc"
"&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22"
"&camera=10,20,5,0,-1,0,0,0,1&fov=60&visibility=ghost"
)
assert url.transport == "ssh"
assert url.fov == 60.0
assert url.visibility == "ghost"
def test_spec_example_orthographic_with_clip():
url = IfcUrl.parse(
"ifc://example.com/org/project@tags/v2.0"
"?path=models/building.ifc"
"&camera=0,0,8,0,0,-1,0,1,0&scale=50&clip=0,0,5,0,0,-1"
)
assert url.scale == 50.0
assert url.clips == [(0.0, 0.0, 5.0, 0.0, 0.0, -1.0)]
def test_spec_example_local():
url = IfcUrl.parse("ifc:///home/alice/projects/office@HEAD?path=model.ifc")
assert url.transport == "local"
assert url.repo_path == "/home/alice/projects/office"
assert url.ref == "HEAD"
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_wrong_scheme():
with pytest.raises(ValueError, match="scheme"):
IfcUrl.parse("https://example.com/org/repo@HEAD")
def test_missing_ref():
with pytest.raises(ValueError, match="ref separator"):
IfcUrl.parse("ifc://example.com/org/repo?path=model.ifc")
def test_camera_wrong_count():
with pytest.raises(ValueError, match="9 values"):
IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&camera=1,2,3&fov=60")
def test_camera_without_projection():
with pytest.raises(ValueError, match="fov.*scale"):
IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&camera=0,0,0,0,0,1,0,1,0")
def test_fov_and_scale_exclusive():
with pytest.raises(ValueError, match="mutually exclusive"):
IfcUrl.parse(
"ifc://example.com/o/r@HEAD?path=m.ifc"
"&camera=0,0,0,0,0,1,0,1,0&fov=60&scale=50"
)
def test_clip_wrong_count():
with pytest.raises(ValueError, match="6 values"):
IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&clip=0,0,1")
def test_unknown_visibility():
with pytest.raises(ValueError, match="visibility"):
IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&visibility=wireframe")
# ---------------------------------------------------------------------------
# to_string()
# ---------------------------------------------------------------------------
def test_to_string_roundtrip_simple():
raw = "ifc://example.com/org/repo@heads/main?path=models/building.ifc"
assert IfcUrl.parse(raw).to_string() == raw
def test_to_string_roundtrip_ssh():
raw = "ifc://git@example.com/org/repo@abc123?path=model.ifc"
assert IfcUrl.parse(raw).to_string() == raw
def test_to_string_roundtrip_local():
raw = "ifc:///home/alice/project@HEAD?path=model.ifc"
assert IfcUrl.parse(raw).to_string() == raw
def test_to_string_roundtrip_perspective_camera():
raw = (
"ifc://example.com/o/r@heads/main"
"?path=model.ifc&camera=1.0,2.0,3.0,0.0,0.0,-1.0,0.0,1.0,0.0&fov=60.0"
)
url = IfcUrl.parse(raw)
parsed_back = IfcUrl.parse(url.to_string())
assert parsed_back.camera == url.camera
assert parsed_back.fov == url.fov
assert parsed_back.scale is None
def test_to_string_roundtrip_orthographic_with_clip():
raw = (
"ifc://example.com/o/r@tags/v2.0"
"?path=model.ifc&camera=0.0,0.0,8.0,0.0,0.0,-1.0,0.0,1.0,0.0"
"&scale=50.0&clip=0.0,0.0,5.0,0.0,0.0,-1.0"
)
url = IfcUrl.parse(raw)
parsed_back = IfcUrl.parse(url.to_string())
assert parsed_back.scale == 50.0
assert parsed_back.clips == [(0.0, 0.0, 5.0, 0.0, 0.0, -1.0)]
def test_to_string_selector_encoded():
url = IfcUrl.parse(
"ifc://example.com/o/r@HEAD?path=m.ifc&selector=IfcWall%2BIfcSlab"
)
assert "selector=IfcWall%2BIfcSlab" in url.to_string()
def test_to_string_no_params():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc")
s = url.to_string()
assert s.startswith("ifc://example.com/org/repo@HEAD")
assert "path=m.ifc" in s
def test_to_string_visibility_ghost():
url = IfcUrl.parse(
"ifc://example.com/o/r@HEAD?path=m.ifc"
"&camera=0.0,0.0,5.0,0.0,0.0,-1.0,0.0,1.0,0.0&fov=60.0&visibility=ghost"
)
assert "visibility=ghost" in url.to_string()
def test_to_string_omits_default_visibility():
url = IfcUrl.parse(
"ifc://example.com/o/r@HEAD?path=m.ifc"
"&camera=0.0,0.0,5.0,0.0,0.0,-1.0,0.0,1.0,0.0&fov=60.0"
)
assert "visibility" not in url.to_string()