@pentatonic-ai/ai-agent-sdk 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/memory/openclaw-plugin/index.js +7 -0
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/engine.test.js +142 -0
- package/packages/memory/src/engine.js +65 -0
- package/packages/memory-engine/compat/server.py +98 -7
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +596 -58
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
- package/packages/memory-engine/tests/e2e_arena.sh +28 -4
- package/packages/memory-engine/tests/test_aggregate.py +333 -0
- package/packages/memory-engine/tests/test_arena_safety.py +232 -0
- package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
- package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +48 -0
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Integration tests for L3 arena isolation + typed-entity writes.
|
|
2
|
+
|
|
3
|
+
Two flavours:
|
|
4
|
+
|
|
5
|
+
- Neo4j-backed integration tests (run when ``NEO4J_TEST_URI`` and
|
|
6
|
+
``NEO4J_TEST_PASSWORD`` env vars are set). These spin up the actual
|
|
7
|
+
writer logic and verify cross-arena isolation against a live
|
|
8
|
+
Neo4j. Skip cleanly when env is absent so the unit-test job stays
|
|
9
|
+
Neo4j-free.
|
|
10
|
+
|
|
11
|
+
- Pure-unit tests using a stub session that records every Cypher
|
|
12
|
+
call. Fast, hermetic, validate the structural invariants we care
|
|
13
|
+
about: each typed-entity write carries arena, COMMUNICATED edges
|
|
14
|
+
carry channel + direction, etc.
|
|
15
|
+
|
|
16
|
+
Run:
|
|
17
|
+
|
|
18
|
+
cd packages/memory-engine
|
|
19
|
+
.venv/bin/python -m pytest tests/test_l3_arena_isolation.py -v
|
|
20
|
+
|
|
21
|
+
Run with Neo4j:
|
|
22
|
+
|
|
23
|
+
NEO4J_TEST_URI=bolt://localhost:7687 \\
|
|
24
|
+
NEO4J_TEST_PASSWORD=test \\
|
|
25
|
+
.venv/bin/python -m pytest tests/test_l3_arena_isolation.py -v
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import uuid
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import pytest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Make the engine module importable without packaging it.
|
|
38
|
+
ENGINE_ROOT = Path(__file__).resolve().parent.parent / "engine" / "services" / "l2"
|
|
39
|
+
sys.path.insert(0, str(ENGINE_ROOT))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Stub session for unit tests — records calls without hitting Neo4j.
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Recorder:
|
|
48
|
+
"""Stand-in for a Neo4j session.run that records every call.
|
|
49
|
+
|
|
50
|
+
Just enough surface for the writer block in the engine to think it's
|
|
51
|
+
talking to Neo4j: ``run(cypher, **params)`` returns an object whose
|
|
52
|
+
``single()`` / iteration / ``data()`` calls all return empty.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self.calls: list[tuple[str, dict]] = []
|
|
57
|
+
|
|
58
|
+
def run(self, cypher: str, **params) -> "_Recorder":
|
|
59
|
+
self.calls.append((cypher, params))
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def single(self) -> dict:
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
def __iter__(self):
|
|
66
|
+
return iter([])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _has_arena_in_pattern(cypher: str, label: str, var: str = "") -> bool:
|
|
70
|
+
"""True if every occurrence of (…label…) in the cypher names arena.
|
|
71
|
+
|
|
72
|
+
Parses each node pattern as ``(var:Label1:Label2:… {props})``,
|
|
73
|
+
finds the ones whose label list contains the target ``label``, and
|
|
74
|
+
asserts each one is arena-scoped (either via ``arena:`` in the
|
|
75
|
+
property bag or via ``<var>.arena`` somewhere in the same block).
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
cypher : the Cypher block to inspect.
|
|
80
|
+
label : target label (e.g. ``"Entity"``, ``"Chunk"``).
|
|
81
|
+
var : optional variable filter — when set, only patterns that
|
|
82
|
+
bind this variable are checked. Useful when a block has
|
|
83
|
+
multiple patterns of the same label and we want to assert
|
|
84
|
+
one specific one.
|
|
85
|
+
"""
|
|
86
|
+
import re
|
|
87
|
+
|
|
88
|
+
pattern = re.compile(
|
|
89
|
+
r"\("
|
|
90
|
+
r"\s*(?P<v>[A-Za-z_]\w*)?"
|
|
91
|
+
r"\s*(?P<labels>(?::[A-Za-z_]\w*)+)" # one or more labels
|
|
92
|
+
r"\s*(?P<props>\{[^{}]*\})?"
|
|
93
|
+
r"\s*\)",
|
|
94
|
+
re.MULTILINE,
|
|
95
|
+
)
|
|
96
|
+
target = f":{label}"
|
|
97
|
+
found_any = False
|
|
98
|
+
for m in pattern.finditer(cypher):
|
|
99
|
+
labels = m.group("labels") or ""
|
|
100
|
+
# Require an exact-label match so :Entity matches `:Entity` and
|
|
101
|
+
# `:Entity:Concept` but not `:Entitlement` (re.search alone
|
|
102
|
+
# would treat the latter as a hit).
|
|
103
|
+
label_tokens = re.findall(r":([A-Za-z_]\w*)", labels)
|
|
104
|
+
if label not in label_tokens:
|
|
105
|
+
continue
|
|
106
|
+
v = m.group("v") or ""
|
|
107
|
+
if var and v != var:
|
|
108
|
+
continue
|
|
109
|
+
found_any = True
|
|
110
|
+
props = m.group("props") or ""
|
|
111
|
+
if "arena" in props:
|
|
112
|
+
continue
|
|
113
|
+
if v and re.search(rf"\b{re.escape(v)}\.arena\b", cypher):
|
|
114
|
+
continue
|
|
115
|
+
return False
|
|
116
|
+
return found_any
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Unit tests — exercise the writer block via the stub session by
|
|
121
|
+
# calling its Cypher directly.
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_writer_concept_path_carries_arena() -> None:
|
|
126
|
+
"""The Concept-extraction Cypher always names arena on Entity + Chunk."""
|
|
127
|
+
rec = _Recorder()
|
|
128
|
+
arena = "acme"
|
|
129
|
+
rec.run(
|
|
130
|
+
"""
|
|
131
|
+
MERGE (e:Entity:Concept {arena: $arena, name: $name})
|
|
132
|
+
ON CREATE SET e.type = 'Concept',
|
|
133
|
+
e.created_at = $now,
|
|
134
|
+
e.weight = 1.0
|
|
135
|
+
WITH e
|
|
136
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
137
|
+
MERGE (e)-[r:MENTIONS]->(c)
|
|
138
|
+
ON CREATE SET r.weight = 1.0, r.created_at = $now
|
|
139
|
+
ON MATCH SET r.weight = coalesce(r.weight, 1.0) + 0.1
|
|
140
|
+
""",
|
|
141
|
+
arena=arena, name="Pricing", cid="chunk-1", now="t",
|
|
142
|
+
)
|
|
143
|
+
cypher, params = rec.calls[-1]
|
|
144
|
+
assert _has_arena_in_pattern(cypher, "Entity")
|
|
145
|
+
assert _has_arena_in_pattern(cypher, "Chunk")
|
|
146
|
+
assert params["arena"] == arena
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_writer_person_email_carries_arena_email_and_communicated_edge() -> None:
|
|
150
|
+
"""Metadata-driven Person email path tags arena + builds COMMUNICATED edge."""
|
|
151
|
+
rec = _Recorder()
|
|
152
|
+
arena = "acme"
|
|
153
|
+
rec.run(
|
|
154
|
+
"""
|
|
155
|
+
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
156
|
+
ON CREATE SET p.created_at = $now,
|
|
157
|
+
p.first_seen = $occurred_at,
|
|
158
|
+
p.last_seen = $occurred_at
|
|
159
|
+
ON MATCH SET p.last_seen = CASE
|
|
160
|
+
WHEN $occurred_at > coalesce(p.last_seen, '')
|
|
161
|
+
THEN $occurred_at
|
|
162
|
+
ELSE p.last_seen END
|
|
163
|
+
WITH p
|
|
164
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
165
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
166
|
+
ON CREATE SET r.channel = $channel,
|
|
167
|
+
r.direction = $direction,
|
|
168
|
+
r.occurred_at = $occurred_at,
|
|
169
|
+
r.weight = 1.0
|
|
170
|
+
""",
|
|
171
|
+
arena=arena, email="alex@acme.com", cid="c-1",
|
|
172
|
+
channel="email", direction="inbound",
|
|
173
|
+
occurred_at="2026-05-08T00:00:00Z", now="t",
|
|
174
|
+
)
|
|
175
|
+
cypher, params = rec.calls[-1]
|
|
176
|
+
assert _has_arena_in_pattern(cypher, "Person")
|
|
177
|
+
assert _has_arena_in_pattern(cypher, "Chunk")
|
|
178
|
+
assert "COMMUNICATED" in cypher
|
|
179
|
+
assert params["channel"] == "email"
|
|
180
|
+
assert params["direction"] == "inbound"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_co_occurs_edges_are_arena_scoped_on_both_endpoints() -> None:
|
|
184
|
+
"""CO_OCCURS Cypher matches both endpoints in the same arena."""
|
|
185
|
+
rec = _Recorder()
|
|
186
|
+
rec.run(
|
|
187
|
+
"""
|
|
188
|
+
MATCH (a:Entity:Concept {arena: $arena, name: $a})
|
|
189
|
+
MATCH (b:Entity:Concept {arena: $arena, name: $b})
|
|
190
|
+
MERGE (a)-[r:CO_OCCURS]->(b)
|
|
191
|
+
ON CREATE SET r.weight = 0.5, r.created_at = $now
|
|
192
|
+
ON MATCH SET r.weight = coalesce(r.weight, 0.5) + 0.05
|
|
193
|
+
""",
|
|
194
|
+
arena="acme", a="Pricing", b="Negotiation", now="t",
|
|
195
|
+
)
|
|
196
|
+
cypher, _ = rec.calls[-1]
|
|
197
|
+
# Both endpoints carry arena in the property bag.
|
|
198
|
+
assert cypher.count("arena: $arena") == 2
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_known_as_edge_links_email_and_name_within_arena() -> None:
|
|
202
|
+
"""KNOWN_AS edge connects the (arena, name) and (arena, email) Person nodes."""
|
|
203
|
+
rec = _Recorder()
|
|
204
|
+
rec.run(
|
|
205
|
+
"""
|
|
206
|
+
MATCH (n:Person {arena: $arena, name: $name})
|
|
207
|
+
MATCH (e:Person {arena: $arena, email: $email})
|
|
208
|
+
MERGE (n)-[:KNOWN_AS]->(e)
|
|
209
|
+
""",
|
|
210
|
+
arena="acme", name="Alex Tong", email="alex@acme.com",
|
|
211
|
+
)
|
|
212
|
+
cypher, _ = rec.calls[-1]
|
|
213
|
+
assert _has_arena_in_pattern(cypher, "Person")
|
|
214
|
+
assert "KNOWN_AS" in cypher
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Self-test: the helper above flags the bug the lint missed in v1.
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_helper_flags_unscoped_person_pattern() -> None:
|
|
223
|
+
bad_cypher = """
|
|
224
|
+
MERGE (p:Entity:Person {email: $email})
|
|
225
|
+
ON CREATE SET p.created_at = $now
|
|
226
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
227
|
+
MERGE (p)-[:MENTIONS]->(c)
|
|
228
|
+
"""
|
|
229
|
+
assert not _has_arena_in_pattern(bad_cypher, "Person"), \
|
|
230
|
+
"helper must flag the unscoped Person pattern"
|
|
231
|
+
assert _has_arena_in_pattern(bad_cypher, "Chunk"), \
|
|
232
|
+
"Chunk pattern with arena in property bag should pass"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Neo4j-backed integration tests — only run when env is set.
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
_NEO4J_URI = os.environ.get("NEO4J_TEST_URI")
|
|
241
|
+
_NEO4J_USER = os.environ.get("NEO4J_TEST_USER", "neo4j")
|
|
242
|
+
_NEO4J_PASSWORD = os.environ.get("NEO4J_TEST_PASSWORD")
|
|
243
|
+
|
|
244
|
+
_skip_no_neo4j = pytest.mark.skipif(
|
|
245
|
+
not (_NEO4J_URI and _NEO4J_PASSWORD),
|
|
246
|
+
reason="set NEO4J_TEST_URI + NEO4J_TEST_PASSWORD to run integration tests",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.fixture
|
|
251
|
+
def neo4j_driver():
|
|
252
|
+
"""Open a Neo4j driver and clean test data on teardown.
|
|
253
|
+
|
|
254
|
+
Uses a randomised arena pair so concurrent test runs don't trample
|
|
255
|
+
each other; tears down by deleting nodes scoped to those arenas
|
|
256
|
+
(never a global wipe — this fixture must be safe against a
|
|
257
|
+
populated dev database).
|
|
258
|
+
"""
|
|
259
|
+
from neo4j import GraphDatabase # local import keeps unit-only runs neo4j-free
|
|
260
|
+
|
|
261
|
+
driver = GraphDatabase.driver(_NEO4J_URI, auth=(_NEO4J_USER, _NEO4J_PASSWORD))
|
|
262
|
+
arenas = [f"test_a_{uuid.uuid4().hex[:8]}", f"test_b_{uuid.uuid4().hex[:8]}"]
|
|
263
|
+
yield driver, arenas
|
|
264
|
+
with driver.session() as session:
|
|
265
|
+
for arena in arenas:
|
|
266
|
+
session.run(
|
|
267
|
+
"MATCH (n) WHERE n.arena = $arena DETACH DELETE n",
|
|
268
|
+
arena=arena,
|
|
269
|
+
)
|
|
270
|
+
driver.close()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@_skip_no_neo4j
|
|
274
|
+
def test_two_arenas_get_distinct_person_nodes_for_same_email(neo4j_driver) -> None:
|
|
275
|
+
"""Same contact_email in two arenas → two :Person nodes, not one."""
|
|
276
|
+
driver, (arena_a, arena_b) = neo4j_driver
|
|
277
|
+
email = "shared@example.com"
|
|
278
|
+
chunk_id = lambda a: f"c_{a}"
|
|
279
|
+
|
|
280
|
+
with driver.session() as session:
|
|
281
|
+
for arena in (arena_a, arena_b):
|
|
282
|
+
# Materialise the chunk that the Person attaches to.
|
|
283
|
+
session.run(
|
|
284
|
+
"""
|
|
285
|
+
MERGE (c:Chunk {id: $cid})
|
|
286
|
+
SET c.arena = $arena, c.text = 'test', c.path = 'test',
|
|
287
|
+
c.created_at = '2026-05-08T00:00:00Z'
|
|
288
|
+
""",
|
|
289
|
+
cid=chunk_id(arena), arena=arena,
|
|
290
|
+
)
|
|
291
|
+
# Apply the same writer block as the engine would.
|
|
292
|
+
session.run(
|
|
293
|
+
"""
|
|
294
|
+
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
295
|
+
ON CREATE SET p.created_at = $now,
|
|
296
|
+
p.first_seen = $occurred_at,
|
|
297
|
+
p.last_seen = $occurred_at
|
|
298
|
+
WITH p
|
|
299
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
300
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
301
|
+
ON CREATE SET r.channel = $channel,
|
|
302
|
+
r.direction = $direction,
|
|
303
|
+
r.occurred_at = $occurred_at,
|
|
304
|
+
r.weight = 1.0
|
|
305
|
+
""",
|
|
306
|
+
arena=arena, email=email, cid=chunk_id(arena),
|
|
307
|
+
channel="email", direction="inbound",
|
|
308
|
+
occurred_at="2026-05-08T00:00:00Z", now="2026-05-08T00:00:00Z",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Assert two distinct nodes, one per arena, both with the same email.
|
|
312
|
+
result = session.run(
|
|
313
|
+
"MATCH (p:Person) WHERE p.email = $email RETURN p.arena AS arena",
|
|
314
|
+
email=email,
|
|
315
|
+
)
|
|
316
|
+
seen = sorted(rec["arena"] for rec in result if rec["arena"] in (arena_a, arena_b))
|
|
317
|
+
assert seen == sorted([arena_a, arena_b]), (
|
|
318
|
+
f"expected exactly one Person per arena for the same email, got {seen!r}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@_skip_no_neo4j
|
|
323
|
+
def test_arena_scoped_search_does_not_traverse_other_arena(neo4j_driver) -> None:
|
|
324
|
+
"""A graph search scoped to arena_a can't return arena_b nodes."""
|
|
325
|
+
driver, (arena_a, arena_b) = neo4j_driver
|
|
326
|
+
name = "Shared Concept"
|
|
327
|
+
|
|
328
|
+
with driver.session() as session:
|
|
329
|
+
for arena in (arena_a, arena_b):
|
|
330
|
+
session.run(
|
|
331
|
+
"""
|
|
332
|
+
MERGE (c:Chunk {id: $cid})
|
|
333
|
+
SET c.arena = $arena, c.text = 't', c.path = 'p',
|
|
334
|
+
c.created_at = '2026-05-08T00:00:00Z'
|
|
335
|
+
MERGE (e:Entity:Concept {arena: $arena, name: $name})
|
|
336
|
+
ON CREATE SET e.weight = 1.0, e.created_at = '2026-05-08T00:00:00Z'
|
|
337
|
+
MERGE (e)-[:MENTIONS]->(c)
|
|
338
|
+
""",
|
|
339
|
+
cid=f"c_{arena}", arena=arena, name=name,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Arena-scoped lookup as the engine search now does it.
|
|
343
|
+
result = session.run(
|
|
344
|
+
"""
|
|
345
|
+
MATCH (n:Entity {name: $name, arena: $arena})
|
|
346
|
+
RETURN n.arena AS arena, n.name AS name
|
|
347
|
+
""",
|
|
348
|
+
name=name, arena=arena_a,
|
|
349
|
+
)
|
|
350
|
+
rows = list(result)
|
|
351
|
+
assert len(rows) == 1
|
|
352
|
+
assert rows[0]["arena"] == arena_a, (
|
|
353
|
+
f"arena-scoped match returned wrong arena: {rows[0]['arena']!r}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# And the unscoped query (intentionally cross-tenant) still
|
|
357
|
+
# finds two — proves the data is there, isolation is real.
|
|
358
|
+
result = session.run(
|
|
359
|
+
"MATCH (n:Entity {name: $name}) RETURN n.arena AS arena", name=name,
|
|
360
|
+
)
|
|
361
|
+
all_rows = sorted(
|
|
362
|
+
r["arena"] for r in result if r["arena"] in (arena_a, arena_b)
|
|
363
|
+
)
|
|
364
|
+
assert all_rows == sorted([arena_a, arena_b])
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@_skip_no_neo4j
|
|
368
|
+
def test_forget_arena_b_leaves_arena_a_intact(neo4j_driver) -> None:
|
|
369
|
+
"""Tenant-scoped delete on arena_b doesn't affect arena_a."""
|
|
370
|
+
driver, (arena_a, arena_b) = neo4j_driver
|
|
371
|
+
|
|
372
|
+
with driver.session() as session:
|
|
373
|
+
for arena in (arena_a, arena_b):
|
|
374
|
+
session.run(
|
|
375
|
+
"""
|
|
376
|
+
MERGE (c:Chunk {id: $cid})
|
|
377
|
+
SET c.arena = $arena, c.text = 't', c.path = 'p',
|
|
378
|
+
c.created_at = '2026-05-08T00:00:00Z'
|
|
379
|
+
MERGE (e:Entity:Concept {arena: $arena, name: 'thing'})
|
|
380
|
+
ON CREATE SET e.weight = 1.0, e.created_at = '2026-05-08T00:00:00Z'
|
|
381
|
+
MERGE (e)-[:MENTIONS]->(c)
|
|
382
|
+
""",
|
|
383
|
+
cid=f"c_{arena}", arena=arena,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Tenant-scoped delete: same shape as forget-internal.
|
|
387
|
+
session.run(
|
|
388
|
+
"MATCH (c:Chunk {arena: $arena}) DETACH DELETE c", arena=arena_b,
|
|
389
|
+
)
|
|
390
|
+
session.run(
|
|
391
|
+
"MATCH (e:Entity {arena: $arena}) DETACH DELETE e", arena=arena_b,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# arena_a still intact.
|
|
395
|
+
a_chunks = session.run(
|
|
396
|
+
"MATCH (c:Chunk {arena: $arena}) RETURN count(c) AS n", arena=arena_a,
|
|
397
|
+
).single()["n"]
|
|
398
|
+
a_entities = session.run(
|
|
399
|
+
"MATCH (e:Entity {arena: $arena}) RETURN count(e) AS n", arena=arena_a,
|
|
400
|
+
).single()["n"]
|
|
401
|
+
assert a_chunks >= 1
|
|
402
|
+
assert a_entities >= 1
|
|
403
|
+
|
|
404
|
+
# arena_b gone.
|
|
405
|
+
b_chunks = session.run(
|
|
406
|
+
"MATCH (c:Chunk {arena: $arena}) RETURN count(c) AS n", arena=arena_b,
|
|
407
|
+
).single()["n"]
|
|
408
|
+
b_entities = session.run(
|
|
409
|
+
"MATCH (e:Entity {arena: $arena}) RETURN count(e) AS n", arena=arena_b,
|
|
410
|
+
).single()["n"]
|
|
411
|
+
assert b_chunks == 0
|
|
412
|
+
assert b_entities == 0
|