Compare commits
1 Commits
main
...
wt/t10-pla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20cfd946c3 |
@@ -26,5 +26,23 @@ CREATE INDEX violation_status_orph IF NOT EXISTS FOR (n:Orphan)
|
||||
CREATE INDEX era_parent IF NOT EXISTS FOR (e:Era) ON (e.parent_slug);
|
||||
CREATE INDEX person_tier IF NOT EXISTS FOR (p:Person) ON (p.tier);
|
||||
|
||||
// ─── Plane model (v3.T10.1) ──────────────────────────────────────────────────
|
||||
// v1.1 used a flat `world_id` string property on every node. The v1.2
|
||||
// amendment (lore-engine/docs/17-planes.md) promotes "world" to a first-
|
||||
// class `Setting` + `Plane` graph: every entity has an `EXISTS_IN` edge
|
||||
// to a `Plane`, every `Plane` belongs to a `Setting` via `HAS_PLANE`,
|
||||
// and reflection / sub-layer relations (`REFLECTS`, `LAYER_OF`) are
|
||||
// first-class edges. The legacy `world_id` property is kept for read-
|
||||
// compat during the migration window and is removed in v2.0.
|
||||
|
||||
CREATE CONSTRAINT setting_id IF NOT EXISTS FOR (s:Setting) REQUIRE s.id IS UNIQUE;
|
||||
CREATE CONSTRAINT plane_id IF NOT EXISTS FOR (p:Plane) REQUIRE p.id IS UNIQUE;
|
||||
|
||||
// Indexes on the most-queried Plane properties: "what planes of kind X
|
||||
// does this setting have?" and "what is the parent of this sub-layer?".
|
||||
CREATE INDEX plane_kind IF NOT EXISTS FOR (p:Plane) ON (p.kind);
|
||||
CREATE INDEX plane_parent_plane IF NOT EXISTS FOR (p:Plane) ON (p.parent_plane);
|
||||
CREATE INDEX plane_setting IF NOT EXISTS FOR (p:Plane) ON (p.setting_id);
|
||||
|
||||
// Era tree: every Era has CONTAINS sub-eras or PART_OF parents
|
||||
// (:Era {slug, name, start, end}) -[:PART_OF]-> (:Era)
|
||||
|
||||
121
seed.py
121
seed.py
@@ -546,6 +546,123 @@ def seed_greyscale_world(driver):
|
||||
f"{len(GS_FACTIONS)} faction, {len(GS_LOCATIONS)} location")
|
||||
|
||||
|
||||
# ─── v3.T10.1: Plane schema migration (data layer) ───────────────────────────
|
||||
#
|
||||
# v1.1 used a flat `world_id: string` property on every node as the world
|
||||
# namespace. The v1.2 amendment (lore-engine/docs/17-planes.md) replaces
|
||||
# that with a first-class `Setting` + `Plane` graph: every entity has an
|
||||
# `EXISTS_IN` edge to a `Plane`, every `Plane` belongs to a `Setting` via
|
||||
# `HAS_PLANE`, and reflection / sub-layer relations (`REFLECTS`, `LAYER_OF`)
|
||||
# are first-class edges.
|
||||
#
|
||||
# This function is the DATA-LAYER side of the migration. It is additive:
|
||||
# the legacy `world_id` property is NOT removed. Plugins keep reading by
|
||||
# `world_id` for now; T10.2 will rewrite the read tools to traverse
|
||||
# `EXISTS_IN` first and fall back to `world_id`. `world_id` is dropped in
|
||||
# v2.0.
|
||||
#
|
||||
# The function is idempotent (every node/edge uses MERGE with a unique
|
||||
# key) and is safe to re-run after the v1.1 seed.
|
||||
|
||||
# Maps the v1.1 world_id string to the (setting_id, plane_id, kind, parent_plane)
|
||||
# tuple the new model uses. world_id was a flat string; setting_id and
|
||||
# plane_id are slugs in the design's `{setting}.{plane}` convention
|
||||
# (with the historical `mardonar` -> `mardonari` typo corrected).
|
||||
_WORLD_TO_PLANE = [
|
||||
# world_id, setting_id, plane_id, kind, parent_plane (or None)
|
||||
("default", "default", "default.material", "material", None),
|
||||
("arda_greyscale","default", "default.arda_greyscale", "demiplane", "default.material"), # REFLECTS
|
||||
("mardonar", "mardonari", "mardonari.material", "material", None),
|
||||
("voldramir", "mardonari", "mardonari.voldramir", "demiplane", "mardonari.material"), # LAYER_OF
|
||||
]
|
||||
|
||||
|
||||
def seed_planes(driver):
|
||||
"""Materialize the v1.2 Setting/Plane graph on top of the v1.1 world.
|
||||
|
||||
For every distinct world_id value in the graph, this:
|
||||
1. Creates the parent :Setting node (idempotent MERGE on id).
|
||||
2. Creates the primary :Plane node for that world (idempotent MERGE).
|
||||
3. Creates the :HAS_PLANE edge from the Setting to the Plane.
|
||||
4. For reflection / sub-layer planes, creates the :REFLECTS or
|
||||
:LAYER_OF edge to the parent plane.
|
||||
5. For every Person/Faction/Location/Item/Event node carrying that
|
||||
world_id, creates an :EXISTS_IN edge to the plane (idempotent).
|
||||
|
||||
The legacy `world_id` property is NOT touched. It stays for read-
|
||||
compat during the migration window.
|
||||
"""
|
||||
with driver.session() as s:
|
||||
# 1-4. Settings, Planes, HAS_PLANE, REFLECTS / LAYER_OF.
|
||||
for world_id, setting_id, plane_id, kind, parent in _WORLD_TO_PLANE:
|
||||
s.run("""
|
||||
MERGE (st:Setting {id: $setting_id})
|
||||
ON CREATE SET st.name = $setting_name,
|
||||
st.summary = $setting_summary
|
||||
MERGE (p:Plane {id: $plane_id})
|
||||
ON CREATE SET p.kind = $kind,
|
||||
p.name = $plane_name,
|
||||
p.setting_id = $setting_id,
|
||||
p.world_id = $world_id,
|
||||
p.accessible = true
|
||||
MERGE (st)-[:HAS_PLANE]->(p)
|
||||
""", setting_id=setting_id,
|
||||
setting_name=setting_id.capitalize(),
|
||||
setting_summary=f"The {setting_id} setting (migrated from v1.1 world_id={world_id}).",
|
||||
plane_id=plane_id,
|
||||
plane_name=plane_id.split(".", 1)[-1].replace("_", " ").title(),
|
||||
kind=kind,
|
||||
world_id=world_id)
|
||||
|
||||
# 4. Reflection / sub-layer edge to parent plane.
|
||||
# Per docs/17-planes.md, a demiplane can be modeled two ways:
|
||||
# - REFLECTS the material plane (a parallel universe that
|
||||
# mirrors its source with twisted qualities — the
|
||||
# Shadowfell / Feywild / Arda-greyscale pattern)
|
||||
# - LAYER_OF the parent plane (a sub-plane, a contained
|
||||
# pocket dimension — the Nine Hells layers, Voldramir)
|
||||
# The right rule is per-plane-intent, not per-kind. The POC
|
||||
# uses REFLECTS for `default.arda_greyscale` (it's explicitly
|
||||
# a dark-mirror of the default Material per the seed design)
|
||||
# and LAYER_OF for `mardonari.voldramir` (a personal demiplane
|
||||
# contained in the Mardonari Material).
|
||||
if parent is None:
|
||||
continue
|
||||
if plane_id == "default.arda_greyscale":
|
||||
s.run("""
|
||||
MATCH (p:Plane {id: $plane_id})
|
||||
MATCH (m:Plane {id: $parent_id})
|
||||
MERGE (p)-[:REFLECTS]->(m)
|
||||
""", plane_id=plane_id, parent_id=parent)
|
||||
else:
|
||||
s.run("""
|
||||
MATCH (p:Plane {id: $plane_id})
|
||||
MATCH (m:Plane {id: $parent_id})
|
||||
MERGE (p)-[:LAYER_OF]->(m)
|
||||
""", plane_id=plane_id, parent_id=parent)
|
||||
|
||||
# 5. EXISTS_IN edges from every world-scoped entity to its plane.
|
||||
for world_id, _setting_id, plane_id, _kind, _parent in _WORLD_TO_PLANE:
|
||||
s.run("""
|
||||
MATCH (n)
|
||||
WHERE n.world_id = $world_id
|
||||
AND (n:Person OR n:Faction OR n:Location OR n:Item OR n:Event)
|
||||
MATCH (p:Plane {id: $plane_id})
|
||||
MERGE (n)-[:EXISTS_IN]->(p)
|
||||
""", world_id=world_id, plane_id=plane_id)
|
||||
|
||||
# Snapshot for the log line.
|
||||
n_settings = s.run("MATCH (n:Setting) RETURN count(n) AS n").single()["n"]
|
||||
n_planes = s.run("MATCH (n:Plane) RETURN count(n) AS n").single()["n"]
|
||||
n_has_plane = s.run("MATCH ()-[r:HAS_PLANE]->() RETURN count(r) AS n").single()["n"]
|
||||
n_exists_in = s.run("MATCH ()-[r:EXISTS_IN]->() RETURN count(r) AS n").single()["n"]
|
||||
n_reflects = s.run("MATCH ()-[r:REFLECTS]->() RETURN count(r) AS n").single()["n"]
|
||||
n_layer_of = s.run("MATCH ()-[r:LAYER_OF]->() RETURN count(r) AS n").single()["n"]
|
||||
print(f"[neo4j] plane schema seeded: {n_settings} settings, {n_planes} planes, "
|
||||
f"{n_has_plane} HAS_PLANE, {n_exists_in} EXISTS_IN, "
|
||||
f"{n_reflects} REFLECTS, {n_layer_of} LAYER_OF edges")
|
||||
|
||||
|
||||
def seed_violations(s):
|
||||
"""Materialize the 5 hand-crafted consistency violations (v2.T5) and the
|
||||
one OntologyRule that drives ontology detection. Idempotent: re-runs
|
||||
@@ -749,6 +866,10 @@ def main():
|
||||
|
||||
seed_neo4j(driver)
|
||||
seed_greyscale_world(driver)
|
||||
# v3.T10.1: layer the v1.2 Setting/Plane graph on top of the v1.1
|
||||
# world_id namespace. Must run after the v1.1 seeds (so EXISTS_IN
|
||||
# can attach to existing Person/Faction/Location/Item/Event nodes).
|
||||
seed_planes(driver)
|
||||
seed_postgres(pg)
|
||||
seed_minio(minio, pg)
|
||||
seed_greyscale_images(minio, pg)
|
||||
|
||||
318
tests/test_planes.py
Normal file
318
tests/test_planes.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Tests for v3.T10.1 — plane schema migration (data layer).
|
||||
|
||||
The v1.1 lore-engine design (docs/01-ontology.md) used `world_id: string`
|
||||
as a flat namespace on every node. The v1.2 amendment (docs/17-planes.md)
|
||||
replaces that with a first-class `Setting` + `Plane` graph: every entity
|
||||
has an `EXISTS_IN` edge to a `Plane`, every `Plane` belongs to a
|
||||
`Setting` via `HAS_PLANE`, and reflection / sub-layer relations
|
||||
(`REFLECTS`, `LAYER_OF`) are first-class edges.
|
||||
|
||||
This file is the DATA-LAYER test contract. T10.1 ships:
|
||||
- `:Setting` and `:Plane` node labels with id uniqueness constraints
|
||||
- Indexes on Plane.kind and Plane.parent_plane
|
||||
- A new `seed_planes()` function in `seed.py` that creates the planes
|
||||
and the EXISTS_IN / HAS_PLANE / REFLECTS / LAYER_OF edges
|
||||
- The new model is layered ON TOP of the legacy `world_id` property
|
||||
(which stays for read-compat during the migration window — see
|
||||
docs/17-planes.md §"Migration from v1.1")
|
||||
|
||||
The tests are read-only against the live Neo4j that `seed.py` populated.
|
||||
They cover the seed for all four world_ids in use today: `default`,
|
||||
`arda_greyscale`, `mardonar`, `voldramir`.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Make gateway/ + plugins/ importable (same pattern as test_multi_world.py)
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
for p in (os.path.join(ROOT, "gateway"), os.path.join(ROOT, "plugins")):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
NEO4J_URL = os.environ.get("TEST_NEO4J_URL", "bolt://localhost:7687")
|
||||
NEO4J_USER = os.environ.get("TEST_NEO4J_USER", "neo4j")
|
||||
NEO4J_PASSWORD = os.environ.get("TEST_NEO4J_PASSWORD", "lore-dev-password")
|
||||
|
||||
|
||||
def _ensure_neo4j_env():
|
||||
os.environ.setdefault("NEO4J_URL", NEO4J_URL)
|
||||
os.environ.setdefault("NEO4J_USER", NEO4J_USER)
|
||||
os.environ.setdefault("NEO4J_PASSWORD", NEO4J_PASSWORD)
|
||||
|
||||
|
||||
def _neo4j_session():
|
||||
from neo4j import GraphDatabase
|
||||
return GraphDatabase.driver(NEO4J_URL, auth=(NEO4J_USER, NEO4J_PASSWORD))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def neo4j():
|
||||
_ensure_neo4j_env()
|
||||
drv = _neo4j_session()
|
||||
yield drv
|
||||
drv.close()
|
||||
|
||||
|
||||
# ─── schema: constraints and indexes ─────────────────────────────────────────
|
||||
|
||||
def test_setting_id_uniqueness_constraint_exists(neo4j):
|
||||
"""`Setting` must have a uniqueness constraint on `id` so the seed
|
||||
can MERGE on it idempotently."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
SHOW CONSTRAINTS YIELD name, entityType, labelsOrTypes, properties
|
||||
WHERE 'Setting' IN labelsOrTypes AND 'id' IN properties
|
||||
RETURN name
|
||||
""").data()
|
||||
assert rows, "no uniqueness constraint on (:Setting {id}) — T10.1 missing?"
|
||||
assert any("id" in (r.get("name") or "").lower() for r in rows) or any(
|
||||
r.get("name") for r in rows
|
||||
), f"unexpected constraint rows: {rows}"
|
||||
|
||||
|
||||
def test_plane_id_uniqueness_constraint_exists(neo4j):
|
||||
"""`Plane` must have a uniqueness constraint on `id`."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
SHOW CONSTRAINTS YIELD name, entityType, labelsOrTypes, properties
|
||||
WHERE 'Plane' IN labelsOrTypes AND 'id' IN properties
|
||||
RETURN name
|
||||
""").data()
|
||||
assert rows, "no uniqueness constraint on (:Plane {id}) — T10.1 missing?"
|
||||
|
||||
|
||||
def test_plane_kind_index_exists(neo4j):
|
||||
"""`Plane.kind` should be indexed — every 'what planes of kind X exist'
|
||||
query (e.g. 'find all demiplanes in this setting') will hit it."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
SHOW INDEXES YIELD name, entityType, labelsOrTypes, properties
|
||||
WHERE 'Plane' IN labelsOrTypes AND 'kind' IN properties
|
||||
RETURN name
|
||||
""").data()
|
||||
assert rows, "no index on (:Plane {kind}) — T10.1 missing?"
|
||||
|
||||
|
||||
# ─── seed: Settings and Planes exist for the 4 world_ids in use ─────────────
|
||||
|
||||
EXPECTED_SETTINGS = {
|
||||
# setting_id -> summary or None
|
||||
"default": None,
|
||||
"mardonari": None, # the v1.1 "mardonar" string maps to setting "mardonari"
|
||||
}
|
||||
|
||||
EXPECTED_PLANES = {
|
||||
# plane_id -> (setting_id, kind, reflects_to or None)
|
||||
"default.material": ("default", "material", None),
|
||||
"default.arda_greyscale": ("default", "demiplane", "default.material"),
|
||||
"mardonari.material": ("mardonari", "material", None),
|
||||
"mardonari.voldramir": ("mardonari", "demiplane", None), # LAYER_OF material
|
||||
}
|
||||
|
||||
|
||||
def test_settings_present(neo4j):
|
||||
"""All 2 Settings must exist (default + mardonari)."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("MATCH (s:Setting) RETURN s.id AS id ORDER BY id").data()
|
||||
ids = {r["id"] for r in rows}
|
||||
missing = set(EXPECTED_SETTINGS) - ids
|
||||
assert not missing, f"missing Settings: {missing}; got: {ids}"
|
||||
|
||||
|
||||
def test_planes_present(neo4j):
|
||||
"""All 4 Planes must exist with the expected kind and parent setting."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
MATCH (st:Setting)-[:HAS_PLANE]->(p:Plane)
|
||||
RETURN p.id AS id, p.kind AS kind, st.id AS setting
|
||||
ORDER BY p.id
|
||||
""").data()
|
||||
by_id = {r["id"]: (r["setting"], r["kind"]) for r in rows}
|
||||
for pid, (want_set, want_kind, _) in EXPECTED_PLANES.items():
|
||||
assert pid in by_id, f"missing Plane: {pid}; got: {list(by_id)}"
|
||||
assert by_id[pid] == (want_set, want_kind), (
|
||||
f"Plane {pid}: expected ({want_set}, {want_kind}), got {by_id[pid]}"
|
||||
)
|
||||
|
||||
|
||||
def test_arda_greyscale_plane_reflects_material(neo4j):
|
||||
"""`default.arda_greyscale` is a REFLECTS edge to `default.material` —
|
||||
that's the 'worlds are planes' insight from docs/17-planes.md."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
MATCH (p:Plane {id: 'default.arda_greyscale'})-[:REFLECTS]->(target:Plane)
|
||||
RETURN target.id AS id, target.kind AS kind
|
||||
""").data()
|
||||
assert len(rows) == 1, f"expected 1 REFLECTS edge, got {rows}"
|
||||
assert rows[0]["id"] == "default.material"
|
||||
assert rows[0]["kind"] == "material"
|
||||
|
||||
|
||||
def test_voldramir_is_demiplane_under_material(neo4j):
|
||||
"""`mardonari.voldramir` is a LAYER_OF `mardonari.material` — Voldramir
|
||||
is the personal demiplane of the Mardonari setting's primary plane."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
MATCH (d:Plane {id: 'mardonari.voldramir'})-[:LAYER_OF]->(parent:Plane)
|
||||
RETURN parent.id AS id
|
||||
""").data()
|
||||
assert len(rows) == 1, f"expected 1 LAYER_OF edge, got {rows}"
|
||||
assert rows[0]["id"] == "mardonari.material"
|
||||
|
||||
|
||||
# ─── seed: every world-scoped entity has an EXISTS_IN edge to its plane ─────
|
||||
|
||||
ENTITY_LABELS = ("Person", "Faction", "Location", "Item", "Event")
|
||||
|
||||
|
||||
def test_default_world_entities_have_exists_in_to_material(neo4j):
|
||||
"""All Person/Faction/Location/Item/Event nodes with world_id='default'
|
||||
must have at least one EXISTS_IN edge, and the target plane must be
|
||||
'default.material'."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run(f"""
|
||||
MATCH (n)
|
||||
WHERE n.world_id = 'default' AND any(l IN labels(n) WHERE l IN $labels)
|
||||
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
|
||||
RETURN n.id AS id, labels(n) AS lbls, collect(p.id) AS planes
|
||||
ORDER BY id
|
||||
""", labels=list(ENTITY_LABELS)).data()
|
||||
# The seed is idempotent — every default-world node must have at least
|
||||
# one plane in its planes list.
|
||||
missing = [r for r in rows if not r["planes"] or r["planes"] == [None]]
|
||||
assert not missing, (
|
||||
f"{len(missing)} default-world entities have no EXISTS_IN edge; "
|
||||
f"first few: {missing[:5]}"
|
||||
)
|
||||
bad_target = [r for r in rows if r["planes"] and r["planes"] != ["default.material"]]
|
||||
assert not bad_target, (
|
||||
f"{len(bad_target)} default-world entities point at a non-Material plane; "
|
||||
f"first few: {bad_target[:5]}"
|
||||
)
|
||||
|
||||
|
||||
def test_arda_greyscale_entities_have_exists_in_to_demiplane(neo4j):
|
||||
"""arda_greyscale entities must point at default.arda_greyscale (the
|
||||
demiplane), not at default.material."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run(f"""
|
||||
MATCH (n)
|
||||
WHERE n.world_id = 'arda_greyscale'
|
||||
AND any(l IN labels(n) WHERE l IN $labels)
|
||||
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
|
||||
RETURN n.id AS id, collect(p.id) AS planes
|
||||
ORDER BY id
|
||||
""", labels=list(ENTITY_LABELS)).data()
|
||||
missing = [r for r in rows if not r["planes"] or r["planes"] == [None]]
|
||||
assert not missing, (
|
||||
f"{len(missing)} greyscale entities have no EXISTS_IN edge; "
|
||||
f"first few: {missing[:5]}"
|
||||
)
|
||||
bad_target = [r for r in rows if r["planes"] != ["default.arda_greyscale"]]
|
||||
assert not bad_target, (
|
||||
f"{len(bad_target)} greyscale entities point at the wrong plane; "
|
||||
f"first few: {bad_target[:5]}"
|
||||
)
|
||||
|
||||
|
||||
def test_mardonar_entities_have_exists_in_to_mardonari_material(neo4j):
|
||||
"""If any entity is namespaced under world_id='mardonar' (the v1.1 string),
|
||||
it must point at mardonari.material."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run(f"""
|
||||
MATCH (n)
|
||||
WHERE n.world_id = 'mardonar'
|
||||
AND any(l IN labels(n) WHERE l IN $labels)
|
||||
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
|
||||
RETURN n.id AS id, collect(p.id) AS planes
|
||||
ORDER BY id
|
||||
""", labels=list(ENTITY_LABELS)).data()
|
||||
# It's OK if no v1.1 mardonar entity exists (the seed only uses
|
||||
# default + arda_greyscale in the POC). But IF they exist, they
|
||||
# must point at mardonari.material.
|
||||
bad_target = [r for r in rows if r["planes"] != ["mardonari.material"]]
|
||||
assert not bad_target, (
|
||||
f"{len(bad_target)} mardonar entities point at the wrong plane; "
|
||||
f"first few: {bad_target[:5]}"
|
||||
)
|
||||
|
||||
|
||||
def test_voldramir_entities_have_exists_in_to_voldramir_demiplane(neo4j):
|
||||
"""Same for world_id='voldramir' — must point at mardonari.voldramir."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run(f"""
|
||||
MATCH (n)
|
||||
WHERE n.world_id = 'voldramir'
|
||||
AND any(l IN labels(n) WHERE l IN $labels)
|
||||
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
|
||||
RETURN n.id AS id, collect(p.id) AS planes
|
||||
ORDER BY id
|
||||
""", labels=list(ENTITY_LABELS)).data()
|
||||
bad_target = [r for r in rows if r["planes"] != ["mardonari.voldramir"]]
|
||||
assert not bad_target, (
|
||||
f"{len(violations := bad_target)} voldramir entities point at the wrong plane; "
|
||||
f"first few: {bad_target[:5]}"
|
||||
)
|
||||
|
||||
|
||||
# ─── seed: legacy world_id property is preserved (read-compat) ──────────────
|
||||
|
||||
def test_legacy_world_id_still_present(neo4j):
|
||||
"""T10.1 is additive — the legacy `world_id` string property must
|
||||
remain on nodes so the v2 plugins keep working unchanged. T10.2 will
|
||||
migrate the read tools; only v2.0 will drop the property."""
|
||||
with neo4j.session() as s:
|
||||
rows = s.run("""
|
||||
MATCH (n:Person)
|
||||
WHERE n.world_id IS NULL
|
||||
RETURN count(n) AS n
|
||||
""").data()
|
||||
assert rows[0]["n"] == 0, (
|
||||
f"some Person nodes lost their world_id property; "
|
||||
f"got {rows[0]['n']} world_id-less nodes"
|
||||
)
|
||||
|
||||
|
||||
# ─── seed idempotency ────────────────────────────────────────────────────────
|
||||
|
||||
def test_seed_planes_is_idempotent(neo4j):
|
||||
"""Running seed_planes() twice must produce the same node + edge counts.
|
||||
Tested by capturing the counts, invoking seed_planes() directly, and
|
||||
re-reading."""
|
||||
# Snapshot before
|
||||
with neo4j.session() as s:
|
||||
before = {
|
||||
"settings": s.run("MATCH (n:Setting) RETURN count(n) AS n").single()["n"],
|
||||
"planes": s.run("MATCH (n:Plane) RETURN count(n) AS n").single()["n"],
|
||||
"has_plane": s.run("MATCH ()-[r:HAS_PLANE]->() RETURN count(r) AS n").single()["n"],
|
||||
"reflects": s.run("MATCH ()-[r:REFLECTS]->() RETURN count(r) AS n").single()["n"],
|
||||
"layer_of": s.run("MATCH ()-[r:LAYER_OF]->() RETURN count(r) AS n").single()["n"],
|
||||
"exists_in": s.run("MATCH ()-[r:EXISTS_IN]->() RETURN count(r) AS n").single()["n"],
|
||||
}
|
||||
|
||||
# Re-run the seeder
|
||||
from seed import seed_planes, load_neo4j
|
||||
drv = load_neo4j()
|
||||
try:
|
||||
seed_planes(drv)
|
||||
finally:
|
||||
drv.close()
|
||||
|
||||
# Snapshot after
|
||||
with neo4j.session() as s:
|
||||
after = {
|
||||
"settings": s.run("MATCH (n:Setting) RETURN count(n) AS n").single()["n"],
|
||||
"planes": s.run("MATCH (n:Plane) RETURN count(n) AS n").single()["n"],
|
||||
"has_plane": s.run("MATCH ()-[r:HAS_PLANE]->() RETURN count(r) AS n").single()["n"],
|
||||
"reflects": s.run("MATCH ()-[r:REFLECTS]->() RETURN count(r) AS n").single()["n"],
|
||||
"layer_of": s.run("MATCH ()-[r:LAYER_OF]->() RETURN count(r) AS n").single()["n"],
|
||||
"exists_in": s.run("MATCH ()-[r:EXISTS_IN]->() RETURN count(r) AS n").single()["n"],
|
||||
}
|
||||
|
||||
for k in before:
|
||||
assert before[k] == after[k], (
|
||||
f"idempotency violated on {k}: before={before[k]} after={after[k]}"
|
||||
)
|
||||
Reference in New Issue
Block a user