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.
Files changed (74) hide show
  1. {consync-2.2.3 → consync-2.3.0}/PKG-INFO +1 -1
  2. {consync-2.2.3 → consync-2.3.0}/consync/__init__.py +1 -1
  3. {consync-2.2.3 → consync-2.3.0}/consync/state.py +32 -4
  4. {consync-2.2.3 → consync-2.3.0}/consync/watcher.py +59 -24
  5. {consync-2.2.3 → consync-2.3.0}/pyproject.toml +1 -1
  6. consync-2.3.0/tests/test_audit_sync.py +669 -0
  7. {consync-2.2.3 → consync-2.3.0}/.github/CODEOWNERS +0 -0
  8. {consync-2.2.3 → consync-2.3.0}/.github/copilot-instructions.md +0 -0
  9. {consync-2.2.3 → consync-2.3.0}/.github/dependabot.yml +0 -0
  10. {consync-2.2.3 → consync-2.3.0}/.github/workflows/ci.yml +0 -0
  11. {consync-2.2.3 → consync-2.3.0}/.github/workflows/codeql.yml +0 -0
  12. {consync-2.2.3 → consync-2.3.0}/.github/workflows/publish.yml +0 -0
  13. {consync-2.2.3 → consync-2.3.0}/.github/workflows/release.yml +0 -0
  14. {consync-2.2.3 → consync-2.3.0}/.gitignore +0 -0
  15. {consync-2.2.3 → consync-2.3.0}/CLAUDE.md +0 -0
  16. {consync-2.2.3 → consync-2.3.0}/CONTRIBUTING.md +0 -0
  17. {consync-2.2.3 → consync-2.3.0}/FAQ.md +0 -0
  18. {consync-2.2.3 → consync-2.3.0}/LICENSE +0 -0
  19. {consync-2.2.3 → consync-2.3.0}/README.md +0 -0
  20. {consync-2.2.3 → consync-2.3.0}/SECURITY.md +0 -0
  21. {consync-2.2.3 → consync-2.3.0}/TODO.md +0 -0
  22. {consync-2.2.3 → consync-2.3.0}/assets/demo.gif +0 -0
  23. {consync-2.2.3 → consync-2.3.0}/assets/demo.tape +0 -0
  24. {consync-2.2.3 → consync-2.3.0}/consync/backup.py +0 -0
  25. {consync-2.2.3 → consync-2.3.0}/consync/cli.py +0 -0
  26. {consync-2.2.3 → consync-2.3.0}/consync/config.py +0 -0
  27. {consync-2.2.3 → consync-2.3.0}/consync/hooks.py +0 -0
  28. {consync-2.2.3 → consync-2.3.0}/consync/lock.py +0 -0
  29. {consync-2.2.3 → consync-2.3.0}/consync/logging_config.py +0 -0
  30. {consync-2.2.3 → consync-2.3.0}/consync/models.py +0 -0
  31. {consync-2.2.3 → consync-2.3.0}/consync/parsers/__init__.py +0 -0
  32. {consync-2.2.3 → consync-2.3.0}/consync/parsers/c_header.py +0 -0
  33. {consync-2.2.3 → consync-2.3.0}/consync/parsers/c_struct_table.py +0 -0
  34. {consync-2.2.3 → consync-2.3.0}/consync/parsers/csv_parser.py +0 -0
  35. {consync-2.2.3 → consync-2.3.0}/consync/parsers/json_parser.py +0 -0
  36. {consync-2.2.3 → consync-2.3.0}/consync/parsers/toml_parser.py +0 -0
  37. {consync-2.2.3 → consync-2.3.0}/consync/parsers/xlsx.py +0 -0
  38. {consync-2.2.3 → consync-2.3.0}/consync/precision.py +0 -0
  39. {consync-2.2.3 → consync-2.3.0}/consync/renderers/__init__.py +0 -0
  40. {consync-2.2.3 → consync-2.3.0}/consync/renderers/c_header.py +0 -0
  41. {consync-2.2.3 → consync-2.3.0}/consync/renderers/c_struct_table.py +0 -0
  42. {consync-2.2.3 → consync-2.3.0}/consync/renderers/csharp.py +0 -0
  43. {consync-2.2.3 → consync-2.3.0}/consync/renderers/csv_renderer.py +0 -0
  44. {consync-2.2.3 → consync-2.3.0}/consync/renderers/json_renderer.py +0 -0
  45. {consync-2.2.3 → consync-2.3.0}/consync/renderers/python_const.py +0 -0
  46. {consync-2.2.3 → consync-2.3.0}/consync/renderers/rust_const.py +0 -0
  47. {consync-2.2.3 → consync-2.3.0}/consync/renderers/verilog.py +0 -0
  48. {consync-2.2.3 → consync-2.3.0}/consync/renderers/vhdl.py +0 -0
  49. {consync-2.2.3 → consync-2.3.0}/consync/sync.py +0 -0
  50. {consync-2.2.3 → consync-2.3.0}/consync/validators.py +0 -0
  51. {consync-2.2.3 → consync-2.3.0}/examples/fpga/.consync.yaml +0 -0
  52. {consync-2.2.3 → consync-2.3.0}/examples/fpga/design_params.csv +0 -0
  53. {consync-2.2.3 → consync-2.3.0}/examples/hardware/.consync.yaml +0 -0
  54. {consync-2.2.3 → consync-2.3.0}/examples/hardware/constants.csv +0 -0
  55. {consync-2.2.3 → consync-2.3.0}/examples/multilang/.consync.yaml +0 -0
  56. {consync-2.2.3 → consync-2.3.0}/examples/multilang/constants.json +0 -0
  57. {consync-2.2.3 → consync-2.3.0}/npm/.npmrc +0 -0
  58. {consync-2.2.3 → consync-2.3.0}/npm/LICENSE +0 -0
  59. {consync-2.2.3 → consync-2.3.0}/npm/README.md +0 -0
  60. {consync-2.2.3 → consync-2.3.0}/npm/bin/consync.js +0 -0
  61. {consync-2.2.3 → consync-2.3.0}/npm/package.json +0 -0
  62. {consync-2.2.3 → consync-2.3.0}/npm/scripts/install.js +0 -0
  63. {consync-2.2.3 → consync-2.3.0}/tests/__init__.py +0 -0
  64. {consync-2.2.3 → consync-2.3.0}/tests/test_arrays.py +0 -0
  65. {consync-2.2.3 → consync-2.3.0}/tests/test_bidirectional.py +0 -0
  66. {consync-2.2.3 → consync-2.3.0}/tests/test_c_struct_table.py +0 -0
  67. {consync-2.2.3 → consync-2.3.0}/tests/test_cli.py +0 -0
  68. {consync-2.2.3 → consync-2.3.0}/tests/test_comprehensive_sync.py +0 -0
  69. {consync-2.2.3 → consync-2.3.0}/tests/test_embedded.py +0 -0
  70. {consync-2.2.3 → consync-2.3.0}/tests/test_parsers.py +0 -0
  71. {consync-2.2.3 → consync-2.3.0}/tests/test_precision.py +0 -0
  72. {consync-2.2.3 → consync-2.3.0}/tests/test_renderers.py +0 -0
  73. {consync-2.2.3 → consync-2.3.0}/tests/test_safety.py +0 -0
  74. {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.2.3
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
@@ -1,6 +1,6 @@
1
1
  """consync — Bidirectional sync between spreadsheets and source code constants."""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.3.0"
4
4
 
5
5
  from consync.models import Constant, SyncDirection
6
6
  from consync.config import load_config
@@ -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
- key = json.dumps(
28
- {c.name: c.value for c in constants},
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 → forced direction
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
- # Determine direction based on which file changed
101
- force = None
102
- for m in cfg.mappings:
103
- source_resolved = (config_dir / m.source).resolve()
104
- target_resolved = (config_dir / m.target).resolve()
105
- if changed_path == source_resolved:
106
- force = "source"
107
- break
108
- elif changed_path == target_resolved:
109
- force = "target"
110
- break
111
-
112
- # Queue the event (never drop)
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] = force
115
- logger.debug("Queued: %s (direction=%s)", changed_path.name, force)
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, force_direction=force)
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.2.3"
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