consync 2.2.3__tar.gz → 2.3.0__tar.gz
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.
- {consync-2.2.3 → consync-2.3.0}/PKG-INFO +1 -1
- {consync-2.2.3 → consync-2.3.0}/consync/__init__.py +1 -1
- {consync-2.2.3 → consync-2.3.0}/consync/state.py +32 -4
- {consync-2.2.3 → consync-2.3.0}/consync/watcher.py +59 -24
- {consync-2.2.3 → consync-2.3.0}/pyproject.toml +1 -1
- consync-2.3.0/tests/test_audit_sync.py +669 -0
- {consync-2.2.3 → consync-2.3.0}/.github/CODEOWNERS +0 -0
- {consync-2.2.3 → consync-2.3.0}/.github/copilot-instructions.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/.github/dependabot.yml +0 -0
- {consync-2.2.3 → consync-2.3.0}/.github/workflows/ci.yml +0 -0
- {consync-2.2.3 → consync-2.3.0}/.github/workflows/codeql.yml +0 -0
- {consync-2.2.3 → consync-2.3.0}/.github/workflows/publish.yml +0 -0
- {consync-2.2.3 → consync-2.3.0}/.github/workflows/release.yml +0 -0
- {consync-2.2.3 → consync-2.3.0}/.gitignore +0 -0
- {consync-2.2.3 → consync-2.3.0}/CLAUDE.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/CONTRIBUTING.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/FAQ.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/LICENSE +0 -0
- {consync-2.2.3 → consync-2.3.0}/README.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/SECURITY.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/TODO.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/assets/demo.gif +0 -0
- {consync-2.2.3 → consync-2.3.0}/assets/demo.tape +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/backup.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/cli.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/config.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/hooks.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/lock.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/logging_config.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/models.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/__init__.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/c_header.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/c_struct_table.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/csv_parser.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/json_parser.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/toml_parser.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/parsers/xlsx.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/precision.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/__init__.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/c_header.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/c_struct_table.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/csharp.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/csv_renderer.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/json_renderer.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/python_const.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/rust_const.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/verilog.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/renderers/vhdl.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/sync.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/consync/validators.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/examples/fpga/.consync.yaml +0 -0
- {consync-2.2.3 → consync-2.3.0}/examples/fpga/design_params.csv +0 -0
- {consync-2.2.3 → consync-2.3.0}/examples/hardware/.consync.yaml +0 -0
- {consync-2.2.3 → consync-2.3.0}/examples/hardware/constants.csv +0 -0
- {consync-2.2.3 → consync-2.3.0}/examples/multilang/.consync.yaml +0 -0
- {consync-2.2.3 → consync-2.3.0}/examples/multilang/constants.json +0 -0
- {consync-2.2.3 → consync-2.3.0}/npm/.npmrc +0 -0
- {consync-2.2.3 → consync-2.3.0}/npm/LICENSE +0 -0
- {consync-2.2.3 → consync-2.3.0}/npm/README.md +0 -0
- {consync-2.2.3 → consync-2.3.0}/npm/bin/consync.js +0 -0
- {consync-2.2.3 → consync-2.3.0}/npm/package.json +0 -0
- {consync-2.2.3 → consync-2.3.0}/npm/scripts/install.js +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/__init__.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_arrays.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_bidirectional.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_c_struct_table.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_cli.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_comprehensive_sync.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_embedded.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_parsers.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_precision.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_renderers.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_safety.py +0 -0
- {consync-2.2.3 → consync-2.3.0}/tests/test_sync.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: consync
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Bidirectional sync between spreadsheets and source code constants — with full decimal precision.
|
|
5
5
|
Project-URL: Homepage, https://github.com/naveenkumarbaskaran/consync
|
|
6
6
|
Project-URL: Repository, https://github.com/naveenkumarbaskaran/consync
|
|
@@ -18,17 +18,45 @@ from pathlib import Path
|
|
|
18
18
|
from consync.models import Constant
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _normalize_value(v):
|
|
22
|
+
"""Normalize a constant value for stable hashing.
|
|
23
|
+
|
|
24
|
+
Ensures int/float equivalence: C produces 1.0 (float) for ``1.0F``,
|
|
25
|
+
but Excel stores it as integer 1. Without normalization these hash
|
|
26
|
+
differently, causing perpetual "out of sync" after every round-trip.
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- int/float that are whole numbers → canonical float (``1.0``)
|
|
30
|
+
- list values → each element normalized recursively
|
|
31
|
+
- strings → unchanged
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(v, list):
|
|
34
|
+
return [_normalize_value(x) for x in v]
|
|
35
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
36
|
+
return float(v)
|
|
37
|
+
return v
|
|
38
|
+
|
|
39
|
+
|
|
21
40
|
def compute_hash(constants: list[Constant]) -> str:
|
|
22
41
|
"""Compute a stable hash of constant name+value pairs.
|
|
23
42
|
|
|
24
43
|
Only hashes names and values (not unit/description) since those
|
|
25
44
|
are the semantically meaningful content for sync detection.
|
|
45
|
+
|
|
46
|
+
Uses a sorted list of (name, value) tuples instead of a dict to
|
|
47
|
+
correctly handle duplicate constant names (e.g., multi-variant
|
|
48
|
+
struct tables where Motor_X__R_Phase appears once per variant).
|
|
49
|
+
A dict would silently discard all but the last duplicate, making
|
|
50
|
+
edits to earlier variants invisible to change detection.
|
|
51
|
+
|
|
52
|
+
Numeric values are normalized to float so that ``int 1`` and
|
|
53
|
+
``float 1.0`` hash identically — this is critical for xlsx
|
|
54
|
+
round-trips where Excel drops the ``.0`` from whole numbers.
|
|
26
55
|
"""
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
sort_keys=True,
|
|
30
|
-
default=str,
|
|
56
|
+
pairs = sorted(
|
|
57
|
+
(c.name, _normalize_value(c.value)) for c in constants
|
|
31
58
|
)
|
|
59
|
+
key = json.dumps(pairs, default=str)
|
|
32
60
|
return hashlib.md5(key.encode()).hexdigest()
|
|
33
61
|
|
|
34
62
|
|
|
@@ -6,6 +6,11 @@ No `brew install` required — pure Python.
|
|
|
6
6
|
Event handling:
|
|
7
7
|
- Changes during debounce are QUEUED, not dropped
|
|
8
8
|
- After debounce expires, all queued events are coalesced into one sync
|
|
9
|
+
- Write suppression prevents ping-pong loops (sync-written files are
|
|
10
|
+
ignored for a short window so they don't re-trigger sync)
|
|
11
|
+
- Direction is NEVER forced — the state engine auto-detects which side
|
|
12
|
+
changed using stored hashes, which correctly returns "already in sync"
|
|
13
|
+
for files that were just written by the sync itself
|
|
9
14
|
- Lock conflicts trigger automatic retry after a short delay
|
|
10
15
|
- Errors are logged but the watcher continues (resilient)
|
|
11
16
|
- On startup, a full sync is run to recover from any drift
|
|
@@ -29,6 +34,10 @@ logger = logging.getLogger(__name__)
|
|
|
29
34
|
LOCK_RETRY_ATTEMPTS = 3
|
|
30
35
|
LOCK_RETRY_DELAY = 2.0 # seconds between retries
|
|
31
36
|
|
|
37
|
+
# Write suppression: ignore events on files we just wrote for this duration (seconds).
|
|
38
|
+
# Must be longer than filesystem event propagation but shorter than user interaction.
|
|
39
|
+
WRITE_SUPPRESSION_WINDOW = 2.0
|
|
40
|
+
|
|
32
41
|
|
|
33
42
|
def start_watcher(
|
|
34
43
|
config_path: str | Path | None = None,
|
|
@@ -84,10 +93,14 @@ def start_watcher(
|
|
|
84
93
|
click.echo(f"[startup] ⚠️ Startup sync failed: {e} — continuing in watch mode.\n")
|
|
85
94
|
|
|
86
95
|
# ── Event queue (thread-safe) — changes are QUEUED, never dropped ──
|
|
87
|
-
pending_changes: dict[Path, str] = {} # path →
|
|
96
|
+
pending_changes: dict[Path, str] = {} # path → info about what changed
|
|
88
97
|
pending_lock = threading.Lock()
|
|
89
98
|
last_sync_time: float = 0
|
|
90
99
|
|
|
100
|
+
# ── Write suppression — tracks files recently written by sync ──
|
|
101
|
+
recently_written: dict[Path, float] = {} # path → timestamp of sync write
|
|
102
|
+
written_lock = threading.Lock()
|
|
103
|
+
|
|
91
104
|
class SyncHandler(FileSystemEventHandler):
|
|
92
105
|
def on_modified(self, event):
|
|
93
106
|
if event.is_directory:
|
|
@@ -97,22 +110,22 @@ def start_watcher(
|
|
|
97
110
|
if changed_path not in watch_files:
|
|
98
111
|
return
|
|
99
112
|
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
#
|
|
113
|
+
# Write suppression: skip events caused by sync's own writes
|
|
114
|
+
with written_lock:
|
|
115
|
+
written_time = recently_written.get(changed_path)
|
|
116
|
+
if written_time and (time.time() - written_time) < WRITE_SUPPRESSION_WINDOW:
|
|
117
|
+
logger.debug(
|
|
118
|
+
"Suppressed event for %s (written %.1fs ago by sync)",
|
|
119
|
+
changed_path.name,
|
|
120
|
+
time.time() - written_time,
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Queue the event (never drop). Don't force direction — let
|
|
125
|
+
# the state engine auto-detect using stored hashes.
|
|
113
126
|
with pending_lock:
|
|
114
|
-
pending_changes[changed_path] =
|
|
115
|
-
logger.debug("Queued: %s
|
|
127
|
+
pending_changes[changed_path] = changed_path.name
|
|
128
|
+
logger.debug("Queued: %s", changed_path.name)
|
|
116
129
|
|
|
117
130
|
def _process_queue():
|
|
118
131
|
"""Process all pending changes in one coalesced sync."""
|
|
@@ -125,24 +138,25 @@ def start_watcher(
|
|
|
125
138
|
queued = dict(pending_changes)
|
|
126
139
|
pending_changes.clear()
|
|
127
140
|
|
|
128
|
-
# Determine overall force direction (if all queued point same way)
|
|
129
|
-
directions = set(queued.values())
|
|
130
|
-
if len(directions) == 1:
|
|
131
|
-
force = directions.pop()
|
|
132
|
-
else:
|
|
133
|
-
force = None # mixed — let engine auto-detect
|
|
134
|
-
|
|
135
141
|
rel_names = [p.name for p in queued.keys()]
|
|
136
142
|
timestamp = time.strftime("%H:%M:%S")
|
|
137
143
|
click.echo(f"[{timestamp}] 📝 {', '.join(rel_names)} changed — syncing...")
|
|
138
144
|
|
|
145
|
+
# Never force direction — let the state engine auto-detect which
|
|
146
|
+
# side changed using stored hashes. This prevents ping-pong:
|
|
147
|
+
# after sync writes file B, B's hash matches the stored hash,
|
|
148
|
+
# so the next cycle correctly detects "already in sync".
|
|
149
|
+
|
|
139
150
|
# Retry on lock conflict
|
|
140
151
|
for attempt in range(LOCK_RETRY_ATTEMPTS):
|
|
141
152
|
try:
|
|
142
|
-
reports = sync(config_path=config_path
|
|
153
|
+
reports = sync(config_path=config_path)
|
|
143
154
|
for r in reports:
|
|
144
155
|
if r.result in (SyncResult.SYNCED_SOURCE_TO_TARGET, SyncResult.SYNCED_TARGET_TO_SOURCE):
|
|
145
156
|
click.echo(f" ✅ {r.message}")
|
|
157
|
+
# Register written files for suppression so their
|
|
158
|
+
# filesystem events don't re-trigger another sync.
|
|
159
|
+
_suppress_written_files(r, config_dir)
|
|
146
160
|
elif r.result == SyncResult.ALREADY_IN_SYNC:
|
|
147
161
|
pass # silent
|
|
148
162
|
elif r.result == SyncResult.ERROR:
|
|
@@ -158,6 +172,27 @@ def start_watcher(
|
|
|
158
172
|
logger.error("Watch sync failed: %s", e)
|
|
159
173
|
return
|
|
160
174
|
|
|
175
|
+
def _suppress_written_files(report, conf_dir):
|
|
176
|
+
"""Mark files written by sync for suppression so their events are ignored."""
|
|
177
|
+
now = time.time()
|
|
178
|
+
with written_lock:
|
|
179
|
+
# Determine which file was written based on sync direction
|
|
180
|
+
if report.result == SyncResult.SYNCED_SOURCE_TO_TARGET:
|
|
181
|
+
# source → target: target was written
|
|
182
|
+
written_path = (conf_dir / report.target).resolve()
|
|
183
|
+
recently_written[written_path] = now
|
|
184
|
+
elif report.result == SyncResult.SYNCED_TARGET_TO_SOURCE:
|
|
185
|
+
# target → source: source was written
|
|
186
|
+
written_path = (conf_dir / report.source).resolve()
|
|
187
|
+
recently_written[written_path] = now
|
|
188
|
+
|
|
189
|
+
# Prune old entries to prevent memory leak
|
|
190
|
+
with written_lock:
|
|
191
|
+
cutoff = now - WRITE_SUPPRESSION_WINDOW * 3
|
|
192
|
+
stale = [p for p, t in recently_written.items() if t < cutoff]
|
|
193
|
+
for p in stale:
|
|
194
|
+
del recently_written[p]
|
|
195
|
+
|
|
161
196
|
observer = Observer()
|
|
162
197
|
handler = SyncHandler()
|
|
163
198
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "consync"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Bidirectional sync between spreadsheets and source code constants — with full decimal precision."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""Comprehensive sync audit — tests every edge case that can cause sync issues.
|
|
2
|
+
|
|
3
|
+
Covers: compute_hash, _determine_direction, _sync_one state updates,
|
|
4
|
+
parser/renderer fidelity, watcher patterns, CLI diff, and round-trip integrity.
|
|
5
|
+
"""
|
|
6
|
+
import textwrap
|
|
7
|
+
import openpyxl
|
|
8
|
+
import pytest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from consync.models import Constant, MappingConfig, SyncDirection
|
|
12
|
+
from consync.state import SyncState, compute_hash
|
|
13
|
+
from consync.sync import sync, check, SyncResult, _determine_direction, _parse_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
17
|
+
# 1. compute_hash — duplicate names, floats, types, edge cases
|
|
18
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
class TestComputeHashDuplicates:
|
|
21
|
+
"""Ensure compute_hash handles duplicate constant names (multi-variant)."""
|
|
22
|
+
|
|
23
|
+
def test_duplicate_names_different_values_differ(self):
|
|
24
|
+
"""Two constants with same name but different values → different hash."""
|
|
25
|
+
c1 = [Constant("X__R", 1.0), Constant("X__R", 2.0)]
|
|
26
|
+
c2 = [Constant("X__R", 1.0), Constant("X__R", 1.0)]
|
|
27
|
+
assert compute_hash(c1) != compute_hash(c2)
|
|
28
|
+
|
|
29
|
+
def test_duplicate_names_same_values_match(self):
|
|
30
|
+
"""Two constants with same name and same values → same hash."""
|
|
31
|
+
c1 = [Constant("X__R", 1.0), Constant("X__R", 2.0)]
|
|
32
|
+
c2 = [Constant("X__R", 1.0), Constant("X__R", 2.0)]
|
|
33
|
+
assert compute_hash(c1) == compute_hash(c2)
|
|
34
|
+
|
|
35
|
+
def test_single_value_change_in_duplicates_detected(self):
|
|
36
|
+
"""Changing one value among duplicates changes the hash."""
|
|
37
|
+
before = [Constant("A", 1.0), Constant("A", 2.0), Constant("A", 3.0)]
|
|
38
|
+
after = [Constant("A", 1.0), Constant("A", 2.5), Constant("A", 3.0)]
|
|
39
|
+
assert compute_hash(before) != compute_hash(after)
|
|
40
|
+
|
|
41
|
+
def test_30_constants_single_edit(self):
|
|
42
|
+
"""Simulates real struct table: 30 constants, change 1 value."""
|
|
43
|
+
before = [Constant(f"Motor_{i}__{f}", float(i * 10 + j))
|
|
44
|
+
for i in range(3) for j, f in enumerate(["R", "L", "Q", "P", "S"])]
|
|
45
|
+
after = list(before)
|
|
46
|
+
# Change one value
|
|
47
|
+
after[7] = Constant(after[7].name, 999.0)
|
|
48
|
+
assert compute_hash(before) != compute_hash(after)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestComputeHashTypes:
|
|
52
|
+
"""Ensure compute_hash distinguishes different types correctly."""
|
|
53
|
+
|
|
54
|
+
def test_int_vs_float_same_value(self):
|
|
55
|
+
"""int 1 vs float 1.0 — should produce different hashes."""
|
|
56
|
+
c_int = [Constant("X", 1)]
|
|
57
|
+
c_float = [Constant("X", 1.0)]
|
|
58
|
+
# Note: json.dumps treats 1 and 1.0 differently
|
|
59
|
+
h1 = compute_hash(c_int)
|
|
60
|
+
h2 = compute_hash(c_float)
|
|
61
|
+
# These may or may not differ depending on json serialization
|
|
62
|
+
# The key thing is consistency, not distinction
|
|
63
|
+
assert isinstance(h1, str) and len(h1) == 32
|
|
64
|
+
|
|
65
|
+
def test_string_values_hashed(self):
|
|
66
|
+
"""String constant values are hashed correctly."""
|
|
67
|
+
c1 = [Constant("X", "MY_MACRO")]
|
|
68
|
+
c2 = [Constant("X", "OTHER_MACRO")]
|
|
69
|
+
assert compute_hash(c1) != compute_hash(c2)
|
|
70
|
+
|
|
71
|
+
def test_empty_list(self):
|
|
72
|
+
"""Empty constant list produces a valid hash."""
|
|
73
|
+
h = compute_hash([])
|
|
74
|
+
assert isinstance(h, str) and len(h) == 32
|
|
75
|
+
|
|
76
|
+
def test_list_values_hashed(self):
|
|
77
|
+
"""Array constant values are hashed via default=str."""
|
|
78
|
+
c1 = [Constant("X", [1, 2, 3])]
|
|
79
|
+
c2 = [Constant("X", [1, 2, 4])]
|
|
80
|
+
assert compute_hash(c1) != compute_hash(c2)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
84
|
+
# 2. _determine_direction — every code path
|
|
85
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
86
|
+
|
|
87
|
+
class TestDetermineDirection:
|
|
88
|
+
"""Test direction detection logic for all modes and edge cases."""
|
|
89
|
+
|
|
90
|
+
def _make_mapping(self, direction=SyncDirection.BOTH):
|
|
91
|
+
return MappingConfig(
|
|
92
|
+
source="src.csv", target="tgt.h",
|
|
93
|
+
source_format="csv", target_format="c_header",
|
|
94
|
+
direction=direction,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_force_source(self, tmp_path):
|
|
98
|
+
"""force_direction='source' always returns 'source'."""
|
|
99
|
+
m = self._make_mapping()
|
|
100
|
+
src = tmp_path / "src.csv"
|
|
101
|
+
tgt = tmp_path / "tgt.h"
|
|
102
|
+
src.write_text("Name,Value\nA,1\n")
|
|
103
|
+
tgt.write_text("const double A = 1;\n")
|
|
104
|
+
state = SyncState(tmp_path / ".state.json")
|
|
105
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
106
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", "source")
|
|
107
|
+
assert result == "source"
|
|
108
|
+
|
|
109
|
+
def test_force_target(self, tmp_path):
|
|
110
|
+
"""force_direction='target' always returns 'target'."""
|
|
111
|
+
m = self._make_mapping()
|
|
112
|
+
src = tmp_path / "src.csv"
|
|
113
|
+
tgt = tmp_path / "tgt.h"
|
|
114
|
+
src.write_text("Name,Value\nA,1\n")
|
|
115
|
+
tgt.write_text("const double A = 1;\n")
|
|
116
|
+
state = SyncState(tmp_path / ".state.json")
|
|
117
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
118
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", "target")
|
|
119
|
+
assert result == "target"
|
|
120
|
+
|
|
121
|
+
def test_s2t_no_target_returns_source(self, tmp_path):
|
|
122
|
+
"""SOURCE_TO_TARGET: target doesn't exist → sync source."""
|
|
123
|
+
m = self._make_mapping(SyncDirection.SOURCE_TO_TARGET)
|
|
124
|
+
src = tmp_path / "src.csv"
|
|
125
|
+
tgt = tmp_path / "tgt.h"
|
|
126
|
+
src.write_text("Name,Value\nA,1\n")
|
|
127
|
+
state = SyncState(tmp_path / ".state.json")
|
|
128
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
129
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
130
|
+
assert result == "source"
|
|
131
|
+
|
|
132
|
+
def test_s2t_source_unchanged_returns_none(self, tmp_path):
|
|
133
|
+
"""SOURCE_TO_TARGET: source hasn't changed → None (already in sync)."""
|
|
134
|
+
m = self._make_mapping(SyncDirection.SOURCE_TO_TARGET)
|
|
135
|
+
src = tmp_path / "src.csv"
|
|
136
|
+
tgt = tmp_path / "tgt.h"
|
|
137
|
+
src.write_text("Name,Value\nA,1\n")
|
|
138
|
+
tgt.write_text("const double A = 1;\n")
|
|
139
|
+
# Set state hash matching current source
|
|
140
|
+
state = SyncState(tmp_path / ".state.json")
|
|
141
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
142
|
+
src_constants = _parse_file(src, "csv")
|
|
143
|
+
src_hash = compute_hash(src_constants)
|
|
144
|
+
state.set_hash(key, src_hash, "whatever")
|
|
145
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
146
|
+
assert result is None
|
|
147
|
+
|
|
148
|
+
def test_t2s_no_source_returns_target(self, tmp_path):
|
|
149
|
+
"""TARGET_TO_SOURCE: source doesn't exist → sync target."""
|
|
150
|
+
m = self._make_mapping(SyncDirection.TARGET_TO_SOURCE)
|
|
151
|
+
src = tmp_path / "src.csv"
|
|
152
|
+
tgt = tmp_path / "tgt.h"
|
|
153
|
+
tgt.write_text("const double A = 1;\n")
|
|
154
|
+
state = SyncState(tmp_path / ".state.json")
|
|
155
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
156
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
157
|
+
assert result == "target"
|
|
158
|
+
|
|
159
|
+
def test_both_no_prior_state_source_wins(self, tmp_path):
|
|
160
|
+
"""BOTH: no prior state, files differ → source wins."""
|
|
161
|
+
m = self._make_mapping(SyncDirection.BOTH)
|
|
162
|
+
src = tmp_path / "src.csv"
|
|
163
|
+
tgt = tmp_path / "tgt.h"
|
|
164
|
+
src.write_text("Name,Value\nA,1\n")
|
|
165
|
+
tgt.write_text("const double A = 2.0;\n")
|
|
166
|
+
state = SyncState(tmp_path / ".state.json")
|
|
167
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
168
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
169
|
+
assert result == "source"
|
|
170
|
+
|
|
171
|
+
def test_both_no_prior_state_same_content_none(self, tmp_path):
|
|
172
|
+
"""BOTH: no prior state, files have same content → None."""
|
|
173
|
+
m = self._make_mapping(SyncDirection.BOTH)
|
|
174
|
+
src = tmp_path / "src.csv"
|
|
175
|
+
tgt = tmp_path / "tgt.h"
|
|
176
|
+
src.write_text("Name,Value\nA,1.0\n")
|
|
177
|
+
tgt.write_text("const double A = 1.0;\n")
|
|
178
|
+
state = SyncState(tmp_path / ".state.json")
|
|
179
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
180
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
181
|
+
assert result is None
|
|
182
|
+
|
|
183
|
+
def test_both_only_source_changed(self, tmp_path):
|
|
184
|
+
"""BOTH: only source changed → source."""
|
|
185
|
+
m = self._make_mapping(SyncDirection.BOTH)
|
|
186
|
+
src = tmp_path / "src.csv"
|
|
187
|
+
tgt = tmp_path / "tgt.h"
|
|
188
|
+
src.write_text("Name,Value\nA,1.0\n")
|
|
189
|
+
tgt.write_text("const double A = 1.0;\n")
|
|
190
|
+
state = SyncState(tmp_path / ".state.json")
|
|
191
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
192
|
+
# Simulate previous sync
|
|
193
|
+
src_hash = compute_hash(_parse_file(src, "csv"))
|
|
194
|
+
tgt_hash = compute_hash(_parse_file(tgt, "c_header"))
|
|
195
|
+
state.set_hash(key, src_hash, tgt_hash)
|
|
196
|
+
# Now change source
|
|
197
|
+
src.write_text("Name,Value\nA,2.0\n")
|
|
198
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
199
|
+
assert result == "source"
|
|
200
|
+
|
|
201
|
+
def test_both_only_target_changed(self, tmp_path):
|
|
202
|
+
"""BOTH: only target changed → target."""
|
|
203
|
+
m = self._make_mapping(SyncDirection.BOTH)
|
|
204
|
+
src = tmp_path / "src.csv"
|
|
205
|
+
tgt = tmp_path / "tgt.h"
|
|
206
|
+
src.write_text("Name,Value\nA,1.0\n")
|
|
207
|
+
tgt.write_text("const double A = 1.0;\n")
|
|
208
|
+
state = SyncState(tmp_path / ".state.json")
|
|
209
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
210
|
+
src_hash = compute_hash(_parse_file(src, "csv"))
|
|
211
|
+
tgt_hash = compute_hash(_parse_file(tgt, "c_header"))
|
|
212
|
+
state.set_hash(key, src_hash, tgt_hash)
|
|
213
|
+
# Now change target
|
|
214
|
+
tgt.write_text("const double A = 9.0;\n")
|
|
215
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
216
|
+
assert result == "target"
|
|
217
|
+
|
|
218
|
+
def test_both_both_changed_conflict(self, tmp_path):
|
|
219
|
+
"""BOTH: both changed, on_conflict='fail' → conflict."""
|
|
220
|
+
m = self._make_mapping(SyncDirection.BOTH)
|
|
221
|
+
src = tmp_path / "src.csv"
|
|
222
|
+
tgt = tmp_path / "tgt.h"
|
|
223
|
+
src.write_text("Name,Value\nA,1.0\n")
|
|
224
|
+
tgt.write_text("const double A = 1.0;\n")
|
|
225
|
+
state = SyncState(tmp_path / ".state.json")
|
|
226
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
227
|
+
src_hash = compute_hash(_parse_file(src, "csv"))
|
|
228
|
+
tgt_hash = compute_hash(_parse_file(tgt, "c_header"))
|
|
229
|
+
state.set_hash(key, src_hash, tgt_hash)
|
|
230
|
+
# Change both
|
|
231
|
+
src.write_text("Name,Value\nA,2.0\n")
|
|
232
|
+
tgt.write_text("const double A = 3.0;\n")
|
|
233
|
+
result = _determine_direction(m, src, tgt, state, key, "fail", None)
|
|
234
|
+
assert result == "conflict"
|
|
235
|
+
|
|
236
|
+
def test_both_both_changed_source_wins(self, tmp_path):
|
|
237
|
+
"""BOTH: both changed, on_conflict='source_wins' → source."""
|
|
238
|
+
m = self._make_mapping(SyncDirection.BOTH)
|
|
239
|
+
src = tmp_path / "src.csv"
|
|
240
|
+
tgt = tmp_path / "tgt.h"
|
|
241
|
+
src.write_text("Name,Value\nA,1.0\n")
|
|
242
|
+
tgt.write_text("const double A = 1.0;\n")
|
|
243
|
+
state = SyncState(tmp_path / ".state.json")
|
|
244
|
+
key = state.mapping_key("src.csv", "tgt.h")
|
|
245
|
+
src_hash = compute_hash(_parse_file(src, "csv"))
|
|
246
|
+
tgt_hash = compute_hash(_parse_file(tgt, "c_header"))
|
|
247
|
+
state.set_hash(key, src_hash, tgt_hash)
|
|
248
|
+
src.write_text("Name,Value\nA,2.0\n")
|
|
249
|
+
tgt.write_text("const double A = 3.0;\n")
|
|
250
|
+
result = _determine_direction(m, src, tgt, state, key, "source_wins", None)
|
|
251
|
+
assert result == "source"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
255
|
+
# 3. _sync_one state update — does state reflect BOTH files after sync?
|
|
256
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
class TestSyncStateUpdates:
|
|
259
|
+
"""After sync, state hashes must match BOTH files so next sync is noop."""
|
|
260
|
+
|
|
261
|
+
@pytest.fixture
|
|
262
|
+
def csv_h_project(self, tmp_path):
|
|
263
|
+
config = tmp_path / ".consync.yaml"
|
|
264
|
+
config.write_text(textwrap.dedent("""\
|
|
265
|
+
mappings:
|
|
266
|
+
- source: data.csv
|
|
267
|
+
target: data.h
|
|
268
|
+
direction: both
|
|
269
|
+
precision: 17
|
|
270
|
+
header_guard: DATA_H
|
|
271
|
+
"""))
|
|
272
|
+
csv = tmp_path / "data.csv"
|
|
273
|
+
csv.write_text("Name,Value,Unit,Description\nPI,3.14159,,Pi\nG,9.81,,Gravity\n")
|
|
274
|
+
return tmp_path, config
|
|
275
|
+
|
|
276
|
+
def test_first_sync_then_noop(self, csv_h_project):
|
|
277
|
+
"""After initial sync, second sync must be 'already in sync'."""
|
|
278
|
+
tmp, cfg = csv_h_project
|
|
279
|
+
r1 = sync(config_path=cfg)
|
|
280
|
+
assert r1[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
281
|
+
r2 = sync(config_path=cfg)
|
|
282
|
+
assert r2[0].result == SyncResult.ALREADY_IN_SYNC
|
|
283
|
+
|
|
284
|
+
def test_edit_source_then_sync_then_noop(self, csv_h_project):
|
|
285
|
+
"""Edit source, sync, then second sync is noop."""
|
|
286
|
+
tmp, cfg = csv_h_project
|
|
287
|
+
sync(config_path=cfg)
|
|
288
|
+
# Edit source
|
|
289
|
+
csv = tmp / "data.csv"
|
|
290
|
+
csv.write_text("Name,Value,Unit,Description\nPI,3.14,,Pi\nG,9.81,,Gravity\n")
|
|
291
|
+
r1 = sync(config_path=cfg)
|
|
292
|
+
assert r1[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
293
|
+
r2 = sync(config_path=cfg)
|
|
294
|
+
assert r2[0].result == SyncResult.ALREADY_IN_SYNC
|
|
295
|
+
|
|
296
|
+
def test_edit_target_then_sync_then_noop(self, csv_h_project):
|
|
297
|
+
"""Edit target, sync, then second sync is noop."""
|
|
298
|
+
tmp, cfg = csv_h_project
|
|
299
|
+
sync(config_path=cfg)
|
|
300
|
+
# Edit target — find the actual rendered PI value and change it
|
|
301
|
+
h = tmp / "data.h"
|
|
302
|
+
content = h.read_text()
|
|
303
|
+
# The renderer outputs full precision, so find the PI line and swap value
|
|
304
|
+
# Replace entire "PI" constant line with a different value
|
|
305
|
+
import re
|
|
306
|
+
content = re.sub(
|
|
307
|
+
r"(PI\s*=\s*)[0-9.]+",
|
|
308
|
+
r"\g<1>2.71828000000000000",
|
|
309
|
+
content,
|
|
310
|
+
)
|
|
311
|
+
h.write_text(content)
|
|
312
|
+
r1 = sync(config_path=cfg)
|
|
313
|
+
assert r1[0].result == SyncResult.SYNCED_TARGET_TO_SOURCE
|
|
314
|
+
r2 = sync(config_path=cfg)
|
|
315
|
+
assert r2[0].result == SyncResult.ALREADY_IN_SYNC
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
319
|
+
# 4. xlsx ↔ c_header flat round-trip
|
|
320
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
321
|
+
|
|
322
|
+
class TestFlatXlsxRoundTrip:
|
|
323
|
+
"""Test xlsx ↔ C header flat layout round-trip fidelity."""
|
|
324
|
+
|
|
325
|
+
@pytest.fixture
|
|
326
|
+
def flat_project(self, tmp_path):
|
|
327
|
+
# Create xlsx
|
|
328
|
+
wb = openpyxl.Workbook()
|
|
329
|
+
ws = wb.active
|
|
330
|
+
ws.title = "Constants"
|
|
331
|
+
ws.append(["Name", "Value", "Unit", "Description"])
|
|
332
|
+
ws.append(["R_SENSE", 1.999, "Ohm", "Sense resistor"])
|
|
333
|
+
ws.append(["R_PULLUP", 4706, "Ohm", "Pull-up"])
|
|
334
|
+
ws.append(["VOLTAGE", 3.3, "V", "Supply voltage"])
|
|
335
|
+
wb.save(tmp_path / "params.xlsx")
|
|
336
|
+
|
|
337
|
+
config = tmp_path / ".consync.yaml"
|
|
338
|
+
config.write_text(textwrap.dedent("""\
|
|
339
|
+
mappings:
|
|
340
|
+
- source: params.xlsx
|
|
341
|
+
target: params.h
|
|
342
|
+
direction: both
|
|
343
|
+
precision: 17
|
|
344
|
+
header_guard: PARAMS_H
|
|
345
|
+
"""))
|
|
346
|
+
return tmp_path, config
|
|
347
|
+
|
|
348
|
+
def test_xlsx_to_c_creates_header(self, flat_project):
|
|
349
|
+
"""xlsx → C header works on first sync."""
|
|
350
|
+
tmp, cfg = flat_project
|
|
351
|
+
reports = sync(config_path=cfg)
|
|
352
|
+
assert reports[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
353
|
+
h = tmp / "params.h"
|
|
354
|
+
assert h.exists()
|
|
355
|
+
content = h.read_text()
|
|
356
|
+
assert "R_SENSE" in content
|
|
357
|
+
assert "R_PULLUP" in content
|
|
358
|
+
assert "VOLTAGE" in content
|
|
359
|
+
|
|
360
|
+
def test_xlsx_to_c_then_noop(self, flat_project):
|
|
361
|
+
"""Second sync after xlsx→C is noop."""
|
|
362
|
+
tmp, cfg = flat_project
|
|
363
|
+
sync(config_path=cfg)
|
|
364
|
+
r2 = sync(config_path=cfg)
|
|
365
|
+
assert r2[0].result == SyncResult.ALREADY_IN_SYNC
|
|
366
|
+
|
|
367
|
+
def test_edit_xlsx_then_sync_updates_c(self, flat_project):
|
|
368
|
+
"""Edit xlsx value, sync picks up change and updates C."""
|
|
369
|
+
tmp, cfg = flat_project
|
|
370
|
+
sync(config_path=cfg)
|
|
371
|
+
# Edit xlsx
|
|
372
|
+
wb = openpyxl.load_workbook(tmp / "params.xlsx")
|
|
373
|
+
ws = wb.active
|
|
374
|
+
ws.cell(2, 2).value = 2.5 # Change R_SENSE
|
|
375
|
+
wb.save(tmp / "params.xlsx")
|
|
376
|
+
r = sync(config_path=cfg)
|
|
377
|
+
assert r[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
378
|
+
content = (tmp / "params.h").read_text()
|
|
379
|
+
assert "2.5" in content
|
|
380
|
+
|
|
381
|
+
def test_edit_c_then_sync_updates_xlsx(self, flat_project):
|
|
382
|
+
"""Edit C header value, sync picks up change and updates xlsx."""
|
|
383
|
+
tmp, cfg = flat_project
|
|
384
|
+
sync(config_path=cfg)
|
|
385
|
+
# Edit C header
|
|
386
|
+
h = tmp / "params.h"
|
|
387
|
+
content = h.read_text()
|
|
388
|
+
content = content.replace("4706U", "5000U")
|
|
389
|
+
h.write_text(content)
|
|
390
|
+
r = sync(config_path=cfg)
|
|
391
|
+
assert r[0].result == SyncResult.SYNCED_TARGET_TO_SOURCE
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
395
|
+
# 5. xlsx ↔ c_struct_table round-trip (the complex one)
|
|
396
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
397
|
+
|
|
398
|
+
class TestStructTableRoundTrip:
|
|
399
|
+
"""Test xlsx ↔ c_struct_table round-trip — the main source of sync issues."""
|
|
400
|
+
|
|
401
|
+
@pytest.fixture
|
|
402
|
+
def struct_project(self, tmp_path):
|
|
403
|
+
c_content = '''\
|
|
404
|
+
#include "types.h"
|
|
405
|
+
|
|
406
|
+
/* Field names: R_Phase L_d L_q Psi Speed_Max */
|
|
407
|
+
|
|
408
|
+
#if (VARIANT == VARIANT_A)
|
|
409
|
+
|
|
410
|
+
static const MotorParam_t params[3] = {
|
|
411
|
+
/* Motor X */ {{0.025F, 0.00003F, 0.00004F, 0.005F, 3000.0F}},
|
|
412
|
+
/* Motor Y */ {{0.030F, 0.00005F, 0.00006F, 0.008F, 4500.0F}},
|
|
413
|
+
/* Motor Z */ {{0.015F, 0.00002F, 0.00003F, 0.003F, 6000.0F}}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
#elif (VARIANT == VARIANT_B)
|
|
417
|
+
|
|
418
|
+
static const MotorParam_t params[3] = {
|
|
419
|
+
/* Motor X */ {{0.045F, 0.00006F, 0.00007F, 0.010F, 2500.0F}},
|
|
420
|
+
/* Motor Y */ {{0.050F, 0.00008F, 0.00009F, 0.012F, 3500.0F}},
|
|
421
|
+
/* Motor Z */ {{0.035F, 0.00004F, 0.00005F, 0.007F, 5000.0F}}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
#endif
|
|
425
|
+
'''
|
|
426
|
+
c_path = tmp_path / "motor_params.c"
|
|
427
|
+
c_path.write_text(c_content)
|
|
428
|
+
|
|
429
|
+
config = tmp_path / ".consync.yaml"
|
|
430
|
+
config.write_text(textwrap.dedent("""\
|
|
431
|
+
mappings:
|
|
432
|
+
- source: motor_params.c
|
|
433
|
+
target: motor_params.xlsx
|
|
434
|
+
format: c_struct_table
|
|
435
|
+
direction: both
|
|
436
|
+
parser_options:
|
|
437
|
+
variant: all
|
|
438
|
+
"""))
|
|
439
|
+
return tmp_path, config, c_content
|
|
440
|
+
|
|
441
|
+
def test_c_to_xlsx_creates_file(self, struct_project):
|
|
442
|
+
"""C struct → xlsx creates properly formatted xlsx."""
|
|
443
|
+
tmp, cfg, _ = struct_project
|
|
444
|
+
r = sync(config_path=cfg)
|
|
445
|
+
assert r[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
446
|
+
assert (tmp / "motor_params.xlsx").exists()
|
|
447
|
+
|
|
448
|
+
def test_c_to_xlsx_then_noop(self, struct_project):
|
|
449
|
+
"""Second sync after C→xlsx is noop."""
|
|
450
|
+
tmp, cfg, _ = struct_project
|
|
451
|
+
sync(config_path=cfg)
|
|
452
|
+
r2 = sync(config_path=cfg)
|
|
453
|
+
assert r2[0].result == SyncResult.ALREADY_IN_SYNC
|
|
454
|
+
|
|
455
|
+
def test_xlsx_edit_detected_and_synced_back(self, struct_project):
|
|
456
|
+
"""Edit xlsx value → sync detects change and updates C file."""
|
|
457
|
+
tmp, cfg, original_c = struct_project
|
|
458
|
+
sync(config_path=cfg)
|
|
459
|
+
|
|
460
|
+
# Edit xlsx: Motor X R_Phase in variant A: 0.025 → 0.1
|
|
461
|
+
wb = openpyxl.load_workbook(tmp / "motor_params.xlsx")
|
|
462
|
+
ws = wb["A"]
|
|
463
|
+
assert ws.cell(2, 1).value == "Motor X"
|
|
464
|
+
ws.cell(2, 2).value = 0.1
|
|
465
|
+
wb.save(tmp / "motor_params.xlsx")
|
|
466
|
+
|
|
467
|
+
r = sync(config_path=cfg)
|
|
468
|
+
assert r[0].result == SyncResult.SYNCED_TARGET_TO_SOURCE
|
|
469
|
+
updated = (tmp / "motor_params.c").read_text()
|
|
470
|
+
assert updated != original_c
|
|
471
|
+
assert "0.100F" in updated or "0.1" in updated
|
|
472
|
+
|
|
473
|
+
def test_xlsx_edit_then_noop(self, struct_project):
|
|
474
|
+
"""After xlsx→C sync, next sync is noop."""
|
|
475
|
+
tmp, cfg, _ = struct_project
|
|
476
|
+
sync(config_path=cfg)
|
|
477
|
+
|
|
478
|
+
wb = openpyxl.load_workbook(tmp / "motor_params.xlsx")
|
|
479
|
+
ws = wb["A"]
|
|
480
|
+
ws.cell(2, 2).value = 0.1
|
|
481
|
+
wb.save(tmp / "motor_params.xlsx")
|
|
482
|
+
|
|
483
|
+
sync(config_path=cfg)
|
|
484
|
+
r3 = sync(config_path=cfg)
|
|
485
|
+
assert r3[0].result == SyncResult.ALREADY_IN_SYNC
|
|
486
|
+
|
|
487
|
+
def test_direction_target_to_source_detects_xlsx_change(self, struct_project):
|
|
488
|
+
"""With direction=target_to_source, xlsx change is detected."""
|
|
489
|
+
tmp, cfg, original_c = struct_project
|
|
490
|
+
|
|
491
|
+
# Rewrite config with t2s direction
|
|
492
|
+
(tmp / ".consync.yaml").write_text(textwrap.dedent("""\
|
|
493
|
+
mappings:
|
|
494
|
+
- source: motor_params.c
|
|
495
|
+
target: motor_params.xlsx
|
|
496
|
+
format: c_struct_table
|
|
497
|
+
direction: target_to_source
|
|
498
|
+
parser_options:
|
|
499
|
+
variant: all
|
|
500
|
+
"""))
|
|
501
|
+
|
|
502
|
+
# First sync: C → xlsx (t2s means target is truth — but target doesn't exist yet)
|
|
503
|
+
# Actually t2s with no source... target is xlsx which doesn't exist yet
|
|
504
|
+
# Let's create xlsx first via s2t, then switch to t2s
|
|
505
|
+
(tmp / ".consync.yaml").write_text(textwrap.dedent("""\
|
|
506
|
+
mappings:
|
|
507
|
+
- source: motor_params.c
|
|
508
|
+
target: motor_params.xlsx
|
|
509
|
+
format: c_struct_table
|
|
510
|
+
direction: source_to_target
|
|
511
|
+
parser_options:
|
|
512
|
+
variant: all
|
|
513
|
+
"""))
|
|
514
|
+
sync(config_path=tmp / ".consync.yaml")
|
|
515
|
+
|
|
516
|
+
# Now edit xlsx
|
|
517
|
+
wb = openpyxl.load_workbook(tmp / "motor_params.xlsx")
|
|
518
|
+
ws = wb["A"]
|
|
519
|
+
ws.cell(2, 2).value = 0.1
|
|
520
|
+
wb.save(tmp / "motor_params.xlsx")
|
|
521
|
+
|
|
522
|
+
# Switch to t2s
|
|
523
|
+
(tmp / ".consync.yaml").write_text(textwrap.dedent("""\
|
|
524
|
+
mappings:
|
|
525
|
+
- source: motor_params.c
|
|
526
|
+
target: motor_params.xlsx
|
|
527
|
+
format: c_struct_table
|
|
528
|
+
direction: target_to_source
|
|
529
|
+
parser_options:
|
|
530
|
+
variant: all
|
|
531
|
+
"""))
|
|
532
|
+
|
|
533
|
+
# Clear state (new config key due to different direction intent)
|
|
534
|
+
state_file = tmp / ".consync.state.json"
|
|
535
|
+
if state_file.exists():
|
|
536
|
+
state_file.unlink()
|
|
537
|
+
|
|
538
|
+
r = sync(config_path=tmp / ".consync.yaml")
|
|
539
|
+
assert r[0].result == SyncResult.SYNCED_TARGET_TO_SOURCE
|
|
540
|
+
updated = (tmp / "motor_params.c").read_text()
|
|
541
|
+
assert "0.100F" in updated or "0.1" in updated
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
545
|
+
# 6. CLI diff — parser_options not passed
|
|
546
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
547
|
+
|
|
548
|
+
class TestDiffParserOptions:
|
|
549
|
+
"""The diff command must pass parser_options to _parse_file."""
|
|
550
|
+
|
|
551
|
+
def test_diff_with_parser_options(self, tmp_path):
|
|
552
|
+
"""diff_cmd should work with c_struct_table format."""
|
|
553
|
+
c_content = '''\
|
|
554
|
+
/* Field names: R_Phase L_d L_q */
|
|
555
|
+
|
|
556
|
+
static const Param_t tbl[2] = {
|
|
557
|
+
/* Row A */ {{1.0F, 2.0F, 3.0F}},
|
|
558
|
+
/* Row B */ {{4.0F, 5.0F, 6.0F}}
|
|
559
|
+
};
|
|
560
|
+
'''
|
|
561
|
+
c_path = tmp_path / "p.c"
|
|
562
|
+
c_path.write_text(c_content)
|
|
563
|
+
config = tmp_path / ".consync.yaml"
|
|
564
|
+
config.write_text(textwrap.dedent("""\
|
|
565
|
+
mappings:
|
|
566
|
+
- source: p.c
|
|
567
|
+
target: p.xlsx
|
|
568
|
+
format: c_struct_table
|
|
569
|
+
direction: source_to_target
|
|
570
|
+
"""))
|
|
571
|
+
# First sync to create xlsx
|
|
572
|
+
sync(config_path=config)
|
|
573
|
+
# Now diff should not crash
|
|
574
|
+
from consync.sync import check as check_sync
|
|
575
|
+
reports = check_sync(config_path=config)
|
|
576
|
+
assert reports[0].result == SyncResult.ALREADY_IN_SYNC
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
580
|
+
# 7. _write_xlsx_flat stale rows — old data not cleared
|
|
581
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
582
|
+
|
|
583
|
+
class TestXlsxFlatStaleRows:
|
|
584
|
+
"""When re-writing xlsx flat, old rows beyond new data must be cleared."""
|
|
585
|
+
|
|
586
|
+
def test_fewer_constants_clears_old_rows(self, tmp_path):
|
|
587
|
+
"""If new data has fewer rows, old rows must be wiped."""
|
|
588
|
+
# Create xlsx with 3 rows
|
|
589
|
+
wb = openpyxl.Workbook()
|
|
590
|
+
ws = wb.active
|
|
591
|
+
ws.title = "Constants"
|
|
592
|
+
ws.append(["Name", "Value", "Unit", "Description"])
|
|
593
|
+
ws.append(["A", 1.0, "", ""])
|
|
594
|
+
ws.append(["B", 2.0, "", ""])
|
|
595
|
+
ws.append(["C", 3.0, "", ""])
|
|
596
|
+
wb.save(tmp_path / "test.xlsx")
|
|
597
|
+
|
|
598
|
+
# Write only 2 constants back
|
|
599
|
+
from consync.sync import _write_xlsx_flat
|
|
600
|
+
from consync.models import MappingConfig
|
|
601
|
+
constants = [Constant("A", 1.0), Constant("B", 2.0)]
|
|
602
|
+
cfg = MappingConfig(source="x.csv", target="test.xlsx")
|
|
603
|
+
_write_xlsx_flat(constants, tmp_path / "test.xlsx", cfg)
|
|
604
|
+
|
|
605
|
+
# Read back — should have exactly 2 data rows
|
|
606
|
+
wb2 = openpyxl.load_workbook(tmp_path / "test.xlsx")
|
|
607
|
+
ws2 = wb2.active
|
|
608
|
+
# Row 4 (old "C" row) should be None
|
|
609
|
+
assert ws2.cell(4, 1).value is None
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
613
|
+
# 8. Watcher-simulated scenario — no ping-pong
|
|
614
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
615
|
+
|
|
616
|
+
class TestNoPingPong:
|
|
617
|
+
"""Simulate watcher scenario: multiple sync() calls shouldn't ping-pong."""
|
|
618
|
+
|
|
619
|
+
def test_triple_sync_no_oscillation(self, tmp_path):
|
|
620
|
+
"""After initial sync, calling sync() 3 more times → all noop."""
|
|
621
|
+
config = tmp_path / ".consync.yaml"
|
|
622
|
+
config.write_text(textwrap.dedent("""\
|
|
623
|
+
mappings:
|
|
624
|
+
- source: data.csv
|
|
625
|
+
target: data.h
|
|
626
|
+
direction: both
|
|
627
|
+
header_guard: DATA_H
|
|
628
|
+
"""))
|
|
629
|
+
csv = tmp_path / "data.csv"
|
|
630
|
+
csv.write_text("Name,Value\nX,42\n")
|
|
631
|
+
|
|
632
|
+
r1 = sync(config_path=config)
|
|
633
|
+
assert r1[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
634
|
+
|
|
635
|
+
for i in range(3):
|
|
636
|
+
r = sync(config_path=config)
|
|
637
|
+
assert r[0].result == SyncResult.ALREADY_IN_SYNC, (
|
|
638
|
+
f"Sync #{i+2} should be noop but got {r[0].result.value}: {r[0].message}"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def test_struct_table_triple_sync(self, tmp_path):
|
|
642
|
+
"""Same test with c_struct_table — the historically problematic format."""
|
|
643
|
+
c_content = '''\
|
|
644
|
+
/* Field names: val1 val2 val3 */
|
|
645
|
+
|
|
646
|
+
static const Param_t tbl[2] = {
|
|
647
|
+
/* Row A */ {{1.0F, 2.0F, 3.0F}},
|
|
648
|
+
/* Row B */ {{4.0F, 5.0F, 6.0F}}
|
|
649
|
+
};
|
|
650
|
+
'''
|
|
651
|
+
c_path = tmp_path / "p.c"
|
|
652
|
+
c_path.write_text(c_content)
|
|
653
|
+
config = tmp_path / ".consync.yaml"
|
|
654
|
+
config.write_text(textwrap.dedent("""\
|
|
655
|
+
mappings:
|
|
656
|
+
- source: p.c
|
|
657
|
+
target: p.xlsx
|
|
658
|
+
format: c_struct_table
|
|
659
|
+
direction: both
|
|
660
|
+
"""))
|
|
661
|
+
|
|
662
|
+
r1 = sync(config_path=config)
|
|
663
|
+
assert r1[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
664
|
+
|
|
665
|
+
for i in range(3):
|
|
666
|
+
r = sync(config_path=config)
|
|
667
|
+
assert r[0].result == SyncResult.ALREADY_IN_SYNC, (
|
|
668
|
+
f"Sync #{i+2} should be noop but got {r[0].result.value}: {r[0].message}"
|
|
669
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|