2026-10-14 19:21:35 +08:00
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import portalocker
|
|
|
|
|
import pytest
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
from gewu.adapters.git_repository import GitCanonicalRepository
|
2026-10-16 20:02:29 +08:00
|
|
|
from gewu.application.registry import (
|
|
|
|
|
MAX_CONTEXT_RECORD_BYTES,
|
|
|
|
|
LocalContextProvider,
|
|
|
|
|
LocalProjectRegistry,
|
|
|
|
|
RegistryError,
|
|
|
|
|
)
|
|
|
|
|
from gewu.domain.contracts import AutoAcceptPolicy, ExtractionBlock, ProjectRecord, ProjectStatus
|
2026-10-14 19:21:35 +08:00
|
|
|
from gewu.domain.validation import extraction_digest
|
|
|
|
|
from tests.domain.proposal_fixtures import EXTRACTION, NODE, POLICIES, PROJECTS
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 20:02:29 +08:00
|
|
|
def test_context_loads_large_normalization_maps_with_a_separate_bound(
|
|
|
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
text = "a" * 100_000
|
|
|
|
|
block = ExtractionBlock(
|
|
|
|
|
block_id="long-block",
|
|
|
|
|
ordinal=0,
|
|
|
|
|
original_text=text,
|
|
|
|
|
normalized_text=text,
|
|
|
|
|
normalization_map=tuple((index, index + 1) for index in range(len(text))),
|
|
|
|
|
)
|
|
|
|
|
candidate = EXTRACTION.model_copy(update={"blocks": (block,)})
|
|
|
|
|
extraction = candidate.model_copy(update={"extraction_id": extraction_digest(candidate)})
|
|
|
|
|
raw = extraction.model_dump_json().encode()
|
|
|
|
|
assert len(raw) > MAX_CONTEXT_RECORD_BYTES
|
|
|
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
|
|
|
|
repository.publish_files(
|
|
|
|
|
expected_head=repository.head(),
|
|
|
|
|
files={f"extractions/{extraction.extraction_id}.json": raw},
|
|
|
|
|
message="Publish long normalized extraction",
|
|
|
|
|
index_directory=tmp_path / "publishing",
|
|
|
|
|
)
|
|
|
|
|
provider = LocalContextProvider(repository, tmp_path / "registry.json")
|
|
|
|
|
|
|
|
|
|
assert provider().extractions[extraction.extraction_id] == extraction
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("gewu.application.registry.MAX_CONTEXT_EXTRACTION_BYTES", len(raw) - 1)
|
|
|
|
|
with pytest.raises(RegistryError, match="Canonical extraction is unavailable or too large"):
|
|
|
|
|
provider()
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 19:21:35 +08:00
|
|
|
def test_project_registry_create_and_archive_are_atomic_and_idempotent(tmp_path: Path) -> None:
|
|
|
|
|
registry_file = tmp_path / "control" / "registry.json"
|
|
|
|
|
registry = LocalProjectRegistry(
|
|
|
|
|
registry_file,
|
|
|
|
|
repository=GitCanonicalRepository(tmp_path / "knowledge.git"),
|
|
|
|
|
index_directory=tmp_path / "publishing",
|
|
|
|
|
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
|
|
|
|
|
)
|
|
|
|
|
project = ProjectRecord(
|
|
|
|
|
project_id="new-research",
|
|
|
|
|
title="New research",
|
|
|
|
|
status=ProjectStatus.ACTIVE,
|
|
|
|
|
owners=("owner-one",),
|
|
|
|
|
north_star="Investigate a new research area.",
|
|
|
|
|
key_questions=("What is known?",),
|
|
|
|
|
autonomy_policy_id="new-policy",
|
|
|
|
|
default_includes=("shared",),
|
|
|
|
|
)
|
|
|
|
|
policy = AutoAcceptPolicy(
|
|
|
|
|
policy_id="new-policy",
|
|
|
|
|
policy_version=1,
|
|
|
|
|
project_id="new-research",
|
|
|
|
|
require_supported_assessment=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert registry.create(project, policy) == project
|
|
|
|
|
assert registry.repository is not None
|
|
|
|
|
revision_after_create = registry.repository.head()
|
|
|
|
|
assert (
|
|
|
|
|
registry.repository.read_file(revision_after_create, "projects/new-research/project.md")
|
|
|
|
|
is not None
|
|
|
|
|
)
|
|
|
|
|
assert registry.repository.list_files(revision_after_create, "policies")
|
|
|
|
|
canonical_context = LocalContextProvider(registry.repository, registry_file)()
|
|
|
|
|
assert canonical_context.projects["new-research"] == project
|
|
|
|
|
assert canonical_context.policies["new-policy"] == policy
|
|
|
|
|
assert registry.records() == (project,)
|
|
|
|
|
assert "new-research" in registry()
|
|
|
|
|
lock_path = tmp_path / "workflow" / "locks" / "canonical-writer.lock"
|
|
|
|
|
with portalocker.Lock(lock_path, mode="a", timeout=0):
|
|
|
|
|
with pytest.raises(portalocker.exceptions.LockException):
|
|
|
|
|
registry.archive("new-research")
|
|
|
|
|
assert registry.archive("new-research").status == ProjectStatus.ARCHIVED
|
|
|
|
|
assert registry.repository.head() != revision_after_create
|
|
|
|
|
assert registry.archive("new-research").status == ProjectStatus.ARCHIVED
|
|
|
|
|
assert registry.records()[0].status == ProjectStatus.ARCHIVED
|
|
|
|
|
assert (
|
|
|
|
|
LocalContextProvider(registry.repository, registry_file)().projects["new-research"].status
|
|
|
|
|
== ProjectStatus.ARCHIVED
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_archive_migrates_legacy_project_to_canonical_registry(tmp_path: Path) -> None:
|
|
|
|
|
registry_file = tmp_path / "control" / "registry.json"
|
|
|
|
|
registry_file.parent.mkdir(parents=True)
|
|
|
|
|
registry_file.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"projects": [project.model_dump(mode="json") for project in PROJECTS.values()],
|
|
|
|
|
"policies": [policy.model_dump(mode="json") for policy in POLICIES.values()],
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
|
|
|
|
registry = LocalProjectRegistry(
|
|
|
|
|
registry_file,
|
|
|
|
|
repository=repository,
|
|
|
|
|
index_directory=tmp_path / "publishing",
|
|
|
|
|
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
archived = registry.archive("dome-performance")
|
|
|
|
|
context = LocalContextProvider(repository, registry_file)()
|
|
|
|
|
|
|
|
|
|
assert archived.status == ProjectStatus.ARCHIVED
|
|
|
|
|
assert context.projects["dome-performance"].status == ProjectStatus.ARCHIVED
|
|
|
|
|
assert context.projects["interactive-video"] == PROJECTS["interactive-video"]
|
|
|
|
|
assert context.policies == POLICIES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_canonical_registry_retains_prior_extractions_after_parser_replacement(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
|
|
|
|
prior = EXTRACTION
|
|
|
|
|
replacement_candidate = prior.model_copy(
|
|
|
|
|
update={"extraction_id": "0" * 64, "parser_version": "replacement-parser-1"}
|
|
|
|
|
)
|
|
|
|
|
replacement = replacement_candidate.model_copy(
|
|
|
|
|
update={"extraction_id": extraction_digest(replacement_candidate)}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
node_path = (
|
|
|
|
|
f"projects/{hashlib.sha256(NODE.project_id.encode()).hexdigest()}/nodes/"
|
|
|
|
|
f"{hashlib.sha256(NODE.node_id.encode()).hexdigest()}.yaml"
|
|
|
|
|
)
|
|
|
|
|
first_revision = repository.publish_files(
|
|
|
|
|
expected_head=repository.head(),
|
|
|
|
|
files={
|
|
|
|
|
f"extractions/{prior.extraction_id}.json": prior.model_dump_json().encode(),
|
|
|
|
|
node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode(),
|
|
|
|
|
},
|
|
|
|
|
message="Publish a cited node using the original parser extraction",
|
|
|
|
|
index_directory=tmp_path / "publishing",
|
|
|
|
|
)
|
|
|
|
|
replacement_revision = repository.publish_files(
|
|
|
|
|
expected_head=repository.head(),
|
|
|
|
|
files={
|
|
|
|
|
f"extractions/{replacement.extraction_id}.json": replacement.model_dump_json().encode()
|
|
|
|
|
},
|
|
|
|
|
message="Add replacement parser extraction without deleting history",
|
|
|
|
|
index_directory=tmp_path / "publishing",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
context = LocalContextProvider(repository, tmp_path / "control/registry.json")()
|
|
|
|
|
assert context.extractions[prior.extraction_id] == prior
|
|
|
|
|
assert context.extractions[replacement.extraction_id] == replacement
|
|
|
|
|
assert context.existing_nodes[NODE.node_id] == NODE
|
|
|
|
|
assert (
|
|
|
|
|
context.existing_nodes[NODE.node_id].claims[0].evidence[0].extraction_id
|
|
|
|
|
== prior.extraction_id
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
repository.read_file(first_revision, f"extractions/{prior.extraction_id}.json") is not None
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
repository.read_file(replacement_revision, f"extractions/{prior.extraction_id}.json")
|
|
|
|
|
is not None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disposable_registry_seed_loads_with_versioned_contracts(
|
|
|
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path))
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[sys.executable, "scripts/seed_test_registry.py"],
|
|
|
|
|
check=False,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
|
|
|
|
context = LocalContextProvider(repository, tmp_path / "control/registry.json")()
|
|
|
|
|
assert set(context.projects) == {"dome-performance", "interactive-video"}
|
|
|
|
|
assert set(context.policies) == {"dome-pilot-policy", "video-pilot-policy", "shared"}
|