ReMe/tests/unit/test_json_file_chunker.py
jinliyl e7d44f6f3b
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
refactor(agent): unify agent subprocess env, sessions, skills, and MCP/service jobs (#382)
* feat(config): add environment variable configuration for agent subprocesses

- Add environment field to ApplicationConfig to store variables for agent subprocesses
- Remove dynamic loading of .env files in agent wrappers
- Introduce subprocess_environment property in base agent wrapper
- Pass application-level environment variables to Claude Code and Codex agents
- Load environment variables once at startup and pass to ReMe application
- Remove dependency on load_env utility in agent wrapper implementations
- Update tests to use configured environment instead of dynamic loading
- Remove unused environment loading utilities and related test cases

* refactor(mcp): remove channel notification system and related components

- Removed channel notification step implementation
- Removed claim channel step implementation
- Removed ChannelSink class from MCP service
- Removed channel-related documentation from AGENTS.md
- Removed channel instruction text from MCP service
- Removed all channel-related tests
- Updated application context metadata comment to remove channel sink reference
- Removed channel module initialization and imports

* feat(service): add job whitelisting capability to BaseService

- Add optional jobs parameter to BaseService.__init__ to configure job whitelist
- Store jobs as set in self.jobs attribute for efficient lookup operations
- Modify add_jobs method to filter jobs based on whitelist configuration
- Update documentation in both English and Chinese to describe new feature
- Add comprehensive unit tests for job whitelisting behavior
- Implement flowchart update showing new filtering logic
- Preserve existing enable_serve flag behavior alongside new whitelisting

* refactor(service): enhance service job validation and MCP tool injection

- Add strict validation for service jobs whitelist with detailed error messages
- Implement injected job arguments support for MCP services with conflict detection
- Add tool error handling for unsuccessful responses in MCP services
- Remove duplicate job names in Codex agent wrapper using dict.fromkeys
- Update MCP server argument format from single JSON array to repeated --job flags
- Add comprehensive test coverage for job injection and error handling scenarios
- Update documentation to reflect service job validation and MCP features
- Ensure application cleanup occurs even when service lifespan encounters errors

* feat(agent): update skill handling to preserve existing Claude skills

- Change skills parameter processing to use 'all' instead of filtered list
- Add logic to select project skills without restricting Claude's existing skills
- Update variable naming from 'skills' to 'selected_skills' for clarity
- Modify application context metadata documentation to clarify in-memory state usage
- Add test case to verify configured skills are added without filtering existing skills
- Update internal skill directory handling to use renamed variable consistently

* refactor(agent): restructure agent wrapper components and session storage

- Move CcFileSessionStore to separate module for better organization
- Add SDK package version logging in base agent wrapper
- Update Claude Code agent to use new session store structure with project keys
- Refactor Claude Code agent wrapper to use proper type hints and SDK integration
- Add support for server tool use events in Claude Code message processing
- Improve error handling and resource cleanup in streaming operations
- Update Codex agent wrapper with proper type annotations and configuration
- Remove deprecated system prompt mode handling from Claude Code wrapper
- Fix session path construction for Claude Code transcript storage
- Update dependency injection and configuration handling patterns

* fix(cc_agent_wrapper): resolve Claude Code SDK integration issues

- Added dataclass import and created _BlockState for content block metadata tracking
- Implemented proper MCP server name constant and tool context ID validation
- Fixed tool_context_id injection to prevent duplicate assignment errors
- Resolved skills parameter handling in build_options method
- Enhanced job tools integration with MCP servers mapping validation
- Replaced deprecated block_ids/block_types/tool_call_names with block_states dict
- Updated message_delta to emit USAGE chunks instead of REPLY_END
- Fixed stream result handling to ensure proper REPLY_END emission
- Improved error handling for session mirror failures and rate limits
- Added proper cleanup for expected trailing errors in streams
- Refactored Codex agent wrapper initialization and configuration management
- Removed obsolete system_prompt_mode from default config
- Enhanced test coverage for new block state and error handling features
- Fixed async generator handling with aclosing context manager
- Improved chunk type mapping for Claude Code SDK events

* refactor(tests): remove demo config tests from config parser test suite

- Removed test_demo_config_registers_llm_jobs function and its assertions
- Eliminated verification of LLM demo job configurations
- Removed checks for agent wrapper component settings
- Deleted assertions for model configurations and parameters
- Cleaned up deprecated test cases related to demo config parsing

* refactor(evolve): simplify Claude Code session store path structure

- Removed redundant project key subdirectory from session link generation
- Updated CcFileSessionStore initialization to use direct session directory path
- Maintained existing session layout compatibility for backward compatibility
- Added unit tests to verify session persistence behavior with existing transcripts
- Ensured UUID-based session files remain accessible at expected locations
- Preserved existing session directory structure without additional nesting

* refactor(agent): defer optional Codex SDK imports until first use

- Moved openai-codex imports inside functions to avoid mandatory dependencies
- Added TYPE_CHECKING guard for development time type checking only
- Implemented lazy loading mechanism with _get_async_codex_class function
- Updated AsyncCodex initialization to occur on demand rather than at module level
- Maintained backward compatibility while improving import performance
- Added test case to verify package import works without optional Codex SDK
- Updated agentscope dependency to version 2.0.4.post1 in pyproject.toml

* test(embedded): add compatibility tests for in-process ReMe embedding

- Add test suite for QwenPaw-style embedded configurations
- Verify optional defaults remain preserved in embedded configs
- Ensure in-process application API stays compatible
- Test model injection and lifecycle management compatibility
- Remove obsolete hermes agent plugin tests
- Update CLI import test to cover multiple optional SDKs
- Block claude_agent_sdk and openai_codex during import testing
2026-07-20 23:52:14 +08:00

614 lines
24 KiB
Python

"""Tests for JsonFileChunker — DFS greedy pruned-tree chunking."""
# pylint: disable=protected-access,missing-class-docstring
# pylint: disable=missing-function-docstring,redefined-outer-name,unused-variable
import asyncio
import json
import os
import tempfile
import pytest
from reme.components.file_chunker import JsonFileChunker
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _run(coro):
return asyncio.run(coro)
def _deep_merge(target: dict, source: dict) -> dict:
for k, v in source.items():
if k in target and isinstance(target[k], dict) and isinstance(v, dict):
_deep_merge(target[k], v)
else:
target[k] = v
return target
def _merge_chunks(chunks) -> dict:
"""Deep-merge all chunk texts into a single dict."""
merged: dict = {}
for c in chunks:
_deep_merge(merged, json.loads(c.text))
return merged
def _deep_merge_full(a, b):
"""Recursively merge *b* into *a*, handling both dicts and arrays."""
if isinstance(a, dict) and isinstance(b, dict):
result = dict(a)
for k, v in b.items():
if k in result:
result[k] = _deep_merge_full(result[k], v)
else:
result[k] = v
return result
if isinstance(a, list) and isinstance(b, list):
result = list(a)
for i, v in enumerate(b):
if i < len(result):
result[i] = _deep_merge_full(result[i], v)
else:
result.append(v)
return result
return b
def _all_leaf_values(data):
"""Yield all scalar leaf values from a JSON structure in DFS order."""
if isinstance(data, dict):
for v in data.values():
yield from _all_leaf_values(v)
elif isinstance(data, list):
for v in data:
yield from _all_leaf_values(v)
else:
yield data
def _is_single_leaf(val) -> bool:
"""True if *val* is a chain of single-element containers ending in a scalar."""
while isinstance(val, (dict, list)):
if len(val) != 1:
return False
val = next(iter(val.values())) if isinstance(val, dict) else val[0]
return True
@pytest.fixture
def make_json():
"""Factory that writes temp JSON files and cleans up afterwards."""
paths: list[str] = []
def _make(data=None, raw: str | None = None) -> str:
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as f:
if raw is not None:
f.write(raw)
elif data is not None:
json.dump(data, f, indent=2)
paths.append(path)
return path
yield _make
for p in paths:
os.unlink(p)
def _build_tree(chunker, data):
"""Build a tree from *data* and return the root node."""
text = json.dumps(data, indent=2)
root, _, _ = chunker._build_tree(text, 0, 1, data)
return root
# ---------------------------------------------------------------------------
# Basic chunking
# ---------------------------------------------------------------------------
class TestBasicChunking:
def test_empty_file(self, make_json):
path = make_json(raw="")
_, chunks = _run(JsonFileChunker().chunk(path))
assert chunks == []
@pytest.mark.parametrize("data", [{}, []])
def test_empty_container(self, make_json, data):
path = make_json(data=data)
_, chunks = _run(JsonFileChunker().chunk(path))
assert len(chunks) == 1
assert json.loads(chunks[0].text) == data
def test_small_json_one_chunk(self, make_json):
data = {"name": "Alice", "age": 30}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=5000).chunk(path))
assert len(chunks) == 1
assert json.loads(chunks[0].text) == data
def test_large_json_multiple_chunks(self, make_json):
data = {f"key_{i}": f"value_{i}" * 20 for i in range(50)}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=500).chunk(path))
assert len(chunks) > 1
all_keys = {k for c in chunks for k in json.loads(c.text)}
assert all_keys == set(data)
def test_nested_deep_merge(self, make_json):
data = {
"section_a": {"name": "Alice", "score": 95, "detail": "x" * 200},
"section_b": {"name": "Bob", "score": 88, "detail": "y" * 200},
"section_c": {"name": "Charlie", "score": 72, "detail": "z" * 200},
}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=400).chunk(path))
assert len(chunks) > 1
assert _merge_chunks(chunks) == data
# ---------------------------------------------------------------------------
# Top-level types
# ---------------------------------------------------------------------------
class TestTopLevelTypes:
@pytest.mark.parametrize(
"raw, expected",
[("42", 42), ('"hello"', "hello"), ("null", None), ("true", True)],
)
def test_primitives(self, make_json, raw, expected):
path = make_json(raw=raw)
_, chunks = _run(JsonFileChunker().chunk(path))
assert len(chunks) == 1
assert json.loads(chunks[0].text) == expected
def test_top_level_array(self, make_json):
path = make_json(data=[{"a": 1}, {"b": 2}])
_, chunks = _run(JsonFileChunker(chunk_chars=5000).chunk(path))
assert len(chunks) >= 1
for c in chunks:
assert isinstance(json.loads(c.text), list)
# ---------------------------------------------------------------------------
# Line-range mapping
# ---------------------------------------------------------------------------
class TestLineRanges:
def test_monotonic(self, make_json):
data = {f"key_{i}": f"val_{i}" for i in range(30)}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=300).chunk(path))
assert len(chunks) > 1
for c in chunks:
assert c.start_line >= 1
assert c.end_line >= c.start_line
assert chunks[0].start_line <= 3
def test_single_chunk_covers_file(self, make_json):
data = {"alpha": {"x": 1}, "beta": {"y": 2}, "gamma": {"z": 3}}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=5000).chunk(path))
assert len(chunks) == 1
assert chunks[0].start_line >= 1
assert chunks[0].end_line >= 5
def test_compact_format(self, make_json):
raw = (
"{\n"
' "alice": {"name": "Alice", "bio": "' + "x" * 200 + '"},\n'
' "bob": {"name": "Bob", "bio": "' + "y" * 200 + '"}\n'
"}"
)
path = make_json(raw=raw)
_, chunks = _run(JsonFileChunker(chunk_chars=300).chunk(path))
assert len(chunks) >= 2
assert _merge_chunks(chunks) == {
"alice": {"name": "Alice", "bio": "x" * 200},
"bob": {"name": "Bob", "bio": "y" * 200},
}
assert chunks[0].start_line == 2
assert chunks[-1].end_line == 3
for i in range(1, len(chunks)):
assert chunks[i].start_line >= chunks[i - 1].start_line
def test_minified_all_line_1(self, make_json):
path = make_json(raw='{"a": 1, "b": 2, "c": 3}')
_, chunks = _run(JsonFileChunker(chunk_chars=50).chunk(path))
for c in chunks:
assert c.start_line == 1 and c.end_line == 1
def test_branch_order(self, make_json):
"""User chunk starts before assistant chunk (DFS order)."""
data = {
"user": {"name": "Alice", "bio": "x" * 200},
"assistant": {"name": "Bob", "bio": "y" * 200},
}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=150).chunk(path))
assert len(chunks) >= 2
# Find first chunk that has only user (not assistant)
user_only = [
c.start_line for c in chunks if "user" in json.loads(c.text) and "assistant" not in json.loads(c.text)
]
asst_chunks = [c.start_line for c in chunks if "assistant" in json.loads(c.text)]
assert user_only and asst_chunks
assert min(user_only) < min(asst_chunks)
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_malformed_fallback(self, make_json):
path = make_json(raw='{"bad": json}')
_, chunks = _run(JsonFileChunker().chunk(path))
assert len(chunks) == 1
assert chunks[0].start_line == 1
def test_chunk_chars_floor(self):
assert JsonFileChunker(chunk_chars=10).chunk_chars == 256
def test_min_element_size_formula(self):
assert JsonFileChunker(chunk_chars=2000).min_element_size == 100 # max(64, 2000*0.05)
assert JsonFileChunker(chunk_chars=100).min_element_size == 64 # max(64, 100*0.05)
assert JsonFileChunker(chunk_chars=10000).min_element_size == 500 # max(64, 10000*0.05)
def test_deeply_nested(self, make_json):
data = {"level1": {"level2": {"level3": {"value": "deep" * 100}}}}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=200).chunk(path))
assert len(chunks) >= 1
merged = _merge_chunks(chunks)
assert merged["level1"]["level2"]["level3"]["value"] == "deep" * 100
def test_unicode(self, make_json):
data = {"greeting": "你好世界", "emoji": "🎉🎊", "mixed": "hello世界"}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker().chunk(path))
assert json.loads(chunks[0].text) == data
def test_chunk_size_respected(self, make_json):
data = {f"k{i}": "v" * 50 for i in range(40)}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=500).chunk(path))
assert len(chunks) > 1
for c in chunks:
assert len(c.text) <= 500 * 3
def test_all_valid_json(self, make_json):
data = {
"users": [{"name": f"u{i}", "desc": "x" * 100, "tags": [f"t{j}" for j in range(5)]} for i in range(10)],
"metadata": {"count": 10, "version": "1.0"},
}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=300).chunk(path))
assert len(chunks) > 1
for c in chunks:
json.loads(c.text) # raises if invalid
def test_cjk_single_chunk(self, make_json):
data = {f"键名_{i}": f"中文值内容_{i}" for i in range(8)}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=400).chunk(path))
assert len(chunks) == 1
def test_same_key_different_levels(self, make_json):
data = {"a": {"a": 1, "b": 2}, "b": {"a": 3}}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=200).chunk(path))
assert _merge_chunks(chunks) == data
def test_string_value_looking_like_key(self, make_json):
data = {"first": "name", "name": "Alice"}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=100).chunk(path))
assert _merge_chunks(chunks) == data
def test_oversized_leaf_atomic(self, make_json):
"""A single leaf exceeding chunk_chars still gets emitted."""
data = {"big": "x" * 5000}
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=500).chunk(path))
assert len(chunks) == 1 # "big" is a leaf (primitive), can't split
# ---------------------------------------------------------------------------
# Array chunking
# ---------------------------------------------------------------------------
class TestArrayChunking:
def test_large_array_all_ids_present(self, make_json):
data = [{"id": i, "data": "x" * 200} for i in range(20)]
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=300).chunk(path))
assert len(chunks) > 1
all_ids = {elem["id"] for c in chunks for elem in json.loads(c.text) if isinstance(elem, dict) and "id" in elem}
assert all_ids == set(range(20))
def test_nested_array_chunking(self, make_json):
"""Array of objects with nested structure is chunked correctly."""
data = [{"name": f"item_{i}", "values": list(range(10))} for i in range(30)]
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=400).chunk(path))
assert len(chunks) > 1
for c in chunks:
assert isinstance(json.loads(c.text), list)
# ---------------------------------------------------------------------------
# Internal: _build_tree, _reconstruct_data
# ---------------------------------------------------------------------------
class TestInternals:
def test_build_tree_structure(self):
# Use large strings so containers exceed min_element_size (250 for chunk_chars=5000)
data = {"aaa": "x" * 500, "bbb": {"ccc": "y" * 500}}
chunker = JsonFileChunker(chunk_chars=5000)
root = _build_tree(chunker, data)
assert root.type == "object"
assert [k for k, _ in root.values] == ["aaa", "bbb"]
# "aaa" is a leaf on line 2
assert root.values[0][1].type == "string"
assert root.values[0][1].start_line == 2
# "bbb" is an internal object on lines 3-5
node_b = root.values[1][1]
assert node_b.type == "object"
assert node_b.start_line == 3
assert [k for k, _ in node_b.values] == ["ccc"]
def test_reconstruct_data(self):
data = {"a": 1, "b": [1, 2, {"c": 3}], "d": {"e": "hello"}}
chunker = JsonFileChunker(chunk_chars=5000)
root = _build_tree(chunker, data)
assert chunker._reconstruct_data(root) == data
def test_node_to_chunks_leaf(self):
chunker = JsonFileChunker()
node = chunker.Node()
node.type = "string"
node.start_line = 1
node.end_line = 1
node.values = json.dumps({"a": 1}, ensure_ascii=False)
chunks = chunker._node_to_chunks(node)
assert len(chunks) == 1
data, s, e = chunks[0]
assert data == {"a": 1}
assert s == 1 and e == 1
# ---------------------------------------------------------------------------
# _SizeNode — incremental size tracking
# ---------------------------------------------------------------------------
class TestSizeNode:
def test_object_size_exact(self):
"""_SizeNode matches json.dumps for flat objects."""
chunker = JsonFileChunker()
tree = chunker._SizeNode(True)
tree.add_leaf(["a"], len(json.dumps(1, ensure_ascii=False)))
assert tree.size == len(json.dumps({"a": 1}, ensure_ascii=False))
tree.add_leaf(["b"], len(json.dumps(2, ensure_ascii=False)))
assert tree.size == len(json.dumps({"a": 1, "b": 2}, ensure_ascii=False))
def test_array_size_exact(self):
chunker = JsonFileChunker()
tree = chunker._SizeNode(False)
tree.add_leaf([0], len(json.dumps(1, ensure_ascii=False)))
tree.add_leaf([1], len(json.dumps("x", ensure_ascii=False)))
assert tree.size == len(json.dumps([1, "x"], ensure_ascii=False))
def test_nested_merge_into_existing_child(self):
"""Two leaves sharing an object key merge correctly."""
chunker = JsonFileChunker()
tree = chunker._SizeNode(True)
sz1 = len(json.dumps(1, ensure_ascii=False))
sz2 = len(json.dumps(2, ensure_ascii=False))
tree.add_leaf(["a", "x"], sz1)
tree.add_leaf(["a", "y"], sz2)
assert tree.size == len(
json.dumps({"a": {"x": 1, "y": 2}}, ensure_ascii=False),
)
def test_accuracy_against_real_chunker(self):
"""Verify _SizeNode matches json.dumps across many accumulation points."""
data = {f"key_{i}": f"val_{i}_" + "x" * 100 for i in range(100)}
chunker = JsonFileChunker(chunk_chars=500)
root = _build_tree(chunker, data)
leaves = list(chunker._collect_leaves(root))
tree = chunker._SizeNode(True)
for i, leaf in enumerate(leaves):
tree.add_leaf(leaf[3], leaf[4])
if (i + 1) % 10 == 0 or i == len(leaves) - 1:
pruned = chunker._build_pruned_tree(leaves[: i + 1])
actual = len(json.dumps(pruned, ensure_ascii=False))
assert tree.size == actual, f"drift at leaf {i}: {tree.size} vs {actual}"
def test_nested_accuracy(self):
"""Accuracy with nested arrays + objects."""
data = {
"a": [{"x": i, "y": i * 2, "z": "hello"} for i in range(50)],
"b": {"c": 1, "d": "test" * 50},
}
chunker = JsonFileChunker(chunk_chars=400)
root = _build_tree(chunker, data)
leaves = list(chunker._collect_leaves(root))
tree = chunker._SizeNode(True)
for i, leaf in enumerate(leaves):
tree.add_leaf(leaf[3], leaf[4])
if (i + 1) % 15 == 0 or i == len(leaves) - 1:
pruned = chunker._build_pruned_tree(leaves[: i + 1])
actual = len(json.dumps(pruned, ensure_ascii=False))
assert tree.size == actual, f"drift at leaf {i}: {tree.size} vs {actual}"
# ---------------------------------------------------------------------------
# DFS greedy algorithm — path wrapping & order
# ---------------------------------------------------------------------------
class TestDfsAlgorithm:
def test_path_wrapping_preserved(self):
"""Each chunk preserves the root-to-leaf nesting structure."""
data = {"a": ["b" * 200, {"c": 1, "d": 2}, "c" * 200, 1], "b": 10}
chunker = JsonFileChunker(chunk_chars=300)
chunker.min_element_size = 1 # force {"c":1,"d":2} to be internal
root = _build_tree(chunker, data)
chunks = chunker._node_to_chunks(root)
assert len(chunks) >= 2
for cdata, sl, el in chunks:
# Every chunk must be a dict with key "a" (path preserved)
assert isinstance(cdata, dict)
assert "a" in cdata
assert isinstance(cdata["a"], list)
def test_calibration_during_chunking(self):
"""Calibration checkpoint keeps size accurate for large inputs."""
data = {f"k{i}": "v" * 50 for i in range(200)}
chunker = JsonFileChunker(chunk_chars=500)
root = _build_tree(chunker, data)
chunks = chunker._node_to_chunks(root)
assert len(chunks) > 1
# Every chunk (except possibly the last single-leaf) should be ≤ chunk_chars
for cdata, _, _ in chunks:
size = len(json.dumps(cdata, ensure_ascii=False))
# Allow single oversized leaf
if size > chunker.chunk_chars:
# This must be a single-leaf chunk
assert len(cdata) == 1 or size <= chunker.chunk_chars * 2
# ---------------------------------------------------------------------------
# Output validation — length, valid JSON, merge equivalence
# ---------------------------------------------------------------------------
class TestOutputValidation:
"""Comprehensive contract tests: every chunk must be valid JSON,
output text length must respect chunk_chars, and merging all chunks
must reconstruct the original data."""
@pytest.mark.parametrize(
"data, chunk_chars",
[
# Flat object
({f"k{i}": f"val_{i}" * 10 for i in range(30)}, 300),
# Nested objects
(
{"a": {"b": {"c": "x" * 100}}, "d": {"e": "y" * 100}, "f": {"g": "z" * 100}},
200,
),
# Array of objects
([{"id": i, "data": "x" * 50} for i in range(20)], 300),
# Mixed nesting
(
{
"users": [{"name": f"u{i}", "tags": [f"t{j}" for j in range(3)]} for i in range(10)],
"meta": {"version": "1.0"},
},
400,
),
# Deep nesting with sibling
(
{"l1": {"l2": {"l3": {"l4": {"v": "deep" * 50}}}, "sibling": "x" * 100}},
256,
),
# CJK content
({f"{i}": f"中文值_{i}" * 10 for i in range(20)}, 300),
# Flat array of scalars
(list(range(200)), 200),
# Object with long string values
({f"k{i}": "x" * 200 for i in range(10)}, 500),
# Large nested array
([{"name": f"item_{i}", "vals": list(range(10))} for i in range(30)], 400),
],
ids=[
"flat_object",
"nested_objects",
"array_of_objects",
"mixed_nesting",
"deep_nesting",
"cjk",
"flat_array",
"long_values",
"nested_array",
],
)
def test_output_contract(self, make_json, data, chunk_chars):
chunker = JsonFileChunker(chunk_chars=chunk_chars)
path = make_json(data=data)
_, chunks = _run(chunker.chunk(path))
assert len(chunks) >= 1
limit = chunker.chunk_chars # accounts for clamping (min 256)
# 1. Every chunk text must be valid JSON
parsed_chunks = []
for i, c in enumerate(chunks):
try:
parsed_chunks.append(json.loads(c.text))
except json.JSONDecodeError as exc:
pytest.fail(f"Chunk {i} is not valid JSON: {exc}")
# 2. Output text length must be <= chunk_chars (actual, after clamp)
# Single-leaf chunks may exceed — that's acceptable.
for i, c in enumerate(chunks):
if len(c.text) > limit:
assert _is_single_leaf(parsed_chunks[i]), (
f"Chunk {i} text length {len(c.text)} > limit={limit} " f"but is not a single leaf"
)
# 3. All leaf values from chunks must reconstruct the original data
# (works for both dict-rooted and array-rooted JSON)
original_leaves = list(_all_leaf_values(data))
chunk_leaves = []
for p in parsed_chunks:
chunk_leaves.extend(_all_leaf_values(p))
assert chunk_leaves == original_leaves, (
f"Leaf values mismatch: expected {len(original_leaves)} leaves, " f"got {len(chunk_leaves)}"
)
def test_length_exact_match_sizenode(self, make_json):
"""Output text length should exactly equal the compact JSON size."""
data = {f"key_{i}": f"val_{i}_" + "x" * 50 for i in range(50)}
chunk_chars = 500
path = make_json(data=data)
_, chunks = _run(JsonFileChunker(chunk_chars=chunk_chars).chunk(path))
assert len(chunks) > 1
for c in chunks:
parsed = json.loads(c.text)
expected_len = len(json.dumps(parsed, ensure_ascii=False))
assert len(c.text) == expected_len, f"Text length {len(c.text)} != compact json size {expected_len}"
# ---------------------------------------------------------------------------
# FileChunk / FileNode properties
# ---------------------------------------------------------------------------
class TestFileProperties:
def test_hash_id_deterministic(self, make_json):
path = make_json(data={"hello": "world"})
_, chunks = _run(JsonFileChunker().chunk(path))
assert len(chunks) == 1
assert chunks[0].id
_, chunks2 = _run(JsonFileChunker().chunk(path))
assert chunks[0].id == chunks2[0].id
def test_node_properties(self, make_json):
path = make_json(data={"a": 1})
node, chunks = _run(JsonFileChunker().chunk(path))
assert node.st_mtime > 0
assert node.links == []
assert node.chunk_ids == [c.id for c in chunks]