1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
|
"""
Reconciling N copies of a playlist when nodes are ON and OFF.
This is the one part of the playlist design that can be properly tested, so
`playlist-merge.js` is kept free of imports and the whole module is executed
here — a copy of the merge rules in a test would keep agreeing with the
original right up until one of them changed.
The stated fear is well founded for a single blob under last-writer-wins: node
A is off while an edit is made, node B is off while the next one is, and one
edit disappears with nothing to show for it. Four rules remove it, and each one
has its own case below. The tombstone rule has two, because dropping it does
not break anything that looks broken: a node rehomed after three weeks quietly
resurrects every deleted playlist, and it reads as a sync working correctly
right up until it doesn't.
See docs/playlists.md §5, §6.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
SRC = STATIC / "playlist-merge.js"
pytestmark = pytest.mark.skipif(
shutil.which("node") is None or not SRC.exists(),
reason="node or the SPA sources are not available")
IMPORT = re.compile(r"^\s*import\b", re.M)
# The export list spans several lines here; source-merge.js's fits on one.
EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M | re.S)
@pytest.fixture(scope="module")
def module_source():
text = SRC.read_text()
assert not IMPORT.search(text), (
"playlist-merge.js has gained an import. It is executed standalone "
"here, and the merge is untested from the moment it cannot be — keep "
"the module free of imports, or this test needs a bundler")
stripped, n = EXPORT.subn("", text)
assert n == 1, (
"playlist-merge.js no longer ends in a single export statement — the "
"test can no longer strip it to run the module")
return stripped
def _run(tmp_path, module_source, body):
script = tmp_path / "case.js"
script.write_text(f"{module_source}\n{body}\n")
out = subprocess.run(
["node", str(script)], capture_output=True, text=True, timeout=30)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
def _eval(tmp_path, module_source, expr):
return _run(tmp_path, module_source,
f"console.log(JSON.stringify({expr}));")
def _entry(name, rev, device, *, body_rev=0, count=0, deleted=False):
return {"name": name, "rev": rev, "device": device,
"body_rev": body_rev, "count": count, "deleted": deleted,
# Carried for display and never read by the merge. Deliberately
# set backwards from `rev` in several cases below: a merge that
# reads the clock gives the wrong answer for all of them.
"updated_at": 1_000_000 - rev}
def _manifest(**playlists):
return {"v": 1, "playlists": playlists}
def _merge(tmp_path, src, a, b):
return _eval(tmp_path, src,
f"mergeManifests({json.dumps(a)}, {json.dumps(b)})")
# ── the unit is one playlist, not the collection ─────────────────────────────
def test_two_playlists_edited_on_two_devices_both_survive(tmp_path, module_source):
"""The overwhelmingly common case for one person with two devices, and the
whole reason the blob is a map rather than a list."""
a = _manifest(evening=_entry("Evening", 4, "dev-a"),
drive=_entry("Drive", 1, "dev-a"))
b = _manifest(evening=_entry("Evening", 1, "dev-b"),
drive=_entry("Drive", 7, "dev-b"))
out = _merge(tmp_path, module_source, a, b)["playlists"]
assert out["evening"]["rev"] == 4 and out["evening"]["device"] == "dev-a"
assert out["drive"]["rev"] == 7 and out["drive"]["device"] == "dev-b"
def test_a_playlist_only_one_side_has_is_kept(tmp_path, module_source):
"""A node that has never seen a playlist must not delete it by omission —
which is the same rule as the tombstone one, from the other end."""
a = _manifest(evening=_entry("Evening", 2, "dev-a"))
b = _manifest(drive=_entry("Drive", 1, "dev-b"))
out = _merge(tmp_path, module_source, a, b)["playlists"]
assert set(out) == {"evening", "drive"}
# ── revisions, never the wall clock ──────────────────────────────────────────
def test_the_higher_revision_wins_even_with_an_older_timestamp(
tmp_path, module_source):
"""`updated_at` is set backwards from `rev` throughout this file. A merge
that reads the clock fails here, and clocks across devices are exactly as
trustworthy as this test assumes."""
a = _manifest(x=_entry("new name", 9, "dev-a"))
b = _manifest(x=_entry("old name", 2, "dev-b"))
out = _merge(tmp_path, module_source, a, b)["playlists"]["x"]
assert out["name"] == "new name"
assert a["playlists"]["x"]["updated_at"] < b["playlists"]["x"]["updated_at"], (
"the fixture must actually have the older clock on the winning side")
def test_a_revision_tie_is_broken_the_same_way_on_every_device(
tmp_path, module_source):
"""Two devices, no conversation between them, one answer. Merged in both
orders because a tie-break that depends on argument order is not one."""
a = _manifest(x=_entry("from A", 5, "dev-a"))
b = _manifest(x=_entry("from B", 5, "dev-b"))
forward = _merge(tmp_path, module_source, a, b)["playlists"]["x"]
backward = _merge(tmp_path, module_source, b, a)["playlists"]["x"]
assert forward == backward
assert forward["device"] == "dev-a"
# ── a deletion is a tombstone, never an absence ──────────────────────────────
def test_a_node_rehomed_after_three_weeks_does_not_resurrect_a_deletion(
tmp_path, module_source):
"""The single most likely defect in the whole design.
The client deleted "Evening" (rev 5, tombstoned). A node that went offline
at rev 4 comes back still holding it, alive. Absence on that node must not
win, and the tombstone must not be dropped just because the other side has
a live entry.
"""
local = _manifest(evening=_entry("Evening", 5, "dev-a", deleted=True))
stale_node = _manifest(evening=_entry("Evening", 4, "dev-a"))
out = _merge(tmp_path, module_source, local, stale_node)["playlists"]
assert out["evening"]["deleted"] is True, "the deleted playlist came back"
def test_a_deletion_loses_to_a_later_edit(tmp_path, module_source):
"""A tombstone is not special-cased into always winning: it is one more
revision. Someone who deletes a playlist and then, from another device that
had not seen the deletion, renames it at a higher revision, gets the
rename — which is last-writer-wins doing exactly what it says."""
deleted = _manifest(x=_entry("X", 5, "dev-a", deleted=True))
later = _manifest(x=_entry("X renamed", 6, "dev-b"))
out = _merge(tmp_path, module_source, deleted, later)["playlists"]["x"]
assert out["deleted"] is False and out["name"] == "X renamed"
def test_a_tombstone_is_not_shown_to_the_reader(tmp_path, module_source):
m = _manifest(gone=_entry("Gone", 3, "dev-a", deleted=True),
kept=_entry("Kept", 1, "dev-a"))
live = _eval(tmp_path, module_source, f"livePlaylists({json.dumps(m)})")
assert [p["id"] for p in live] == ["kept"]
# ── the two counters ─────────────────────────────────────────────────────────
def test_a_rename_and_a_track_added_elsewhere_both_survive(
tmp_path, module_source):
"""Why there are two counters rather than one.
Device A renames the playlist; device B adds a track to it. Neither edit
touches the other, but with a single revision counter both write n+1 and
one of them is lost. `rev` carries the name, `body_rev` is a watermark for
the tracks, and they move independently.
"""
renamed = _manifest(x=_entry("New name", 8, "dev-a", body_rev=3, count=10))
tracked = _manifest(x=_entry("Old name", 7, "dev-b", body_rev=4, count=11))
out = _merge(tmp_path, module_source, renamed, tracked)["playlists"]["x"]
assert out["name"] == "New name", "the rename was lost"
assert out["body_rev"] == 4 and out["count"] == 11, "the added track was lost"
def test_the_body_watermark_never_goes_backwards(tmp_path, module_source):
"""A stale node reporting body_rev 2 against a local 9 must not lower it —
the local copy is one of the inputs, and that is what stops a node that
serves an old copy from being able to undo anything."""
local = _manifest(x=_entry("X", 4, "dev-a", body_rev=9, count=40))
stale = _manifest(x=_entry("X", 4, "dev-a", body_rev=2, count=5))
out = _merge(tmp_path, module_source, local, stale)["playlists"]["x"]
assert out["body_rev"] == 9 and out["count"] == 40
# ── the client is the authority ──────────────────────────────────────────────
def test_the_offline_node_dance_loses_nothing(tmp_path, module_source):
"""The scenario the fear is actually about, played out.
Device 1 renames Evening while node B is off; device 2 renames Drive while
node A is off. Each node holds one of the two edits. The client folds its
own copy together with both and keeps both edits — which it can only do
because its own copy is one of the inputs.
"""
body = """
const base = {v:1, playlists: {
evening: {name:'Evening', rev:1, device:'dev-a', body_rev:1, count:2, deleted:false},
drive: {name:'Drive', rev:1, device:'dev-a', body_rev:1, count:2, deleted:false}}};
const nodeA = JSON.parse(JSON.stringify(base));
nodeA.playlists.evening = {...nodeA.playlists.evening, name:'Soirée', rev:2, device:'dev-1'};
const nodeB = JSON.parse(JSON.stringify(base));
nodeB.playlists.drive = {...nodeB.playlists.drive, name:'Route', rev:2, device:'dev-2'};
const merged = mergeAllManifests([base, nodeA, nodeB]);
console.log(JSON.stringify({
evening: merged.playlists.evening.name,
drive: merged.playlists.drive.name}));
"""
out = _run(tmp_path, module_source, body)
assert out == {"evening": "Soirée", "drive": "Route"}
def test_folding_in_any_order_gives_the_same_answer(tmp_path, module_source):
"""Nodes answer in whatever order they answer in; the merged state cannot
depend on that."""
body = """
const c = [
{v:1, playlists:{x:{name:'a', rev:1, device:'d1', body_rev:1, count:1, deleted:false}}},
{v:1, playlists:{x:{name:'b', rev:3, device:'d2', body_rev:5, count:9, deleted:false}}},
{v:1, playlists:{x:{name:'c', rev:2, device:'d3', body_rev:2, count:4, deleted:false}}},
];
const one = mergeAllManifests(c);
const two = mergeAllManifests([c[2], c[0], c[1]]);
const three = mergeAllManifests([c[1], c[2], c[0]]);
console.log(JSON.stringify([one, two, three]));
"""
one, two, three = _run(tmp_path, module_source, body)
assert one == two == three
assert one["playlists"]["x"]["name"] == "b"
# ── bodies ───────────────────────────────────────────────────────────────────
def test_a_body_merge_takes_the_higher_revision(tmp_path, module_source):
a = {"v": 1, "id": "x", "rev": 4, "device": "d1", "tracks": [{"id": "t1"}]}
b = {"v": 1, "id": "x", "rev": 6, "device": "d2",
"tracks": [{"id": "t1"}, {"id": "t2"}]}
out = _eval(tmp_path, module_source,
f"mergeBodies({json.dumps(a)}, {json.dumps(b)})")
assert out["rev"] == 6 and len(out["tracks"]) == 2
def test_a_body_a_node_has_never_seen_is_not_an_absence(tmp_path, module_source):
a = {"v": 1, "id": "x", "rev": 3, "device": "d1", "tracks": [{"id": "t1"}]}
assert _eval(tmp_path, module_source,
f"mergeBodies({json.dumps(a)}, null)")["rev"] == 3
assert _eval(tmp_path, module_source,
f"mergeBodies(null, {json.dumps(a)})")["rev"] == 3
# ── what is stored, and what must not be ─────────────────────────────────────
def test_a_stored_track_carries_the_fields_the_player_cannot_work_without(
tmp_path, module_source):
"""`s` and `n` are the two the first draft of the design left out. The
player computes its chunk count from the size and reads the name both for
the MIME type and to decide whether the file needs converting first, so
without them a playlist entry cannot be downloaded at all."""
entry = {"id": "abc", "name": "03 - A Track.flac", "size": 41238711,
"path": "Artist/Album", "type": "audio", "duration": 214,
"display_title": "A Track", "artist": "Artist", "album": "Album",
"track_no": 3, "hash_version": 2}
stored = _eval(tmp_path, module_source,
f"toStored({json.dumps(entry)}, 'g1')")
assert stored["s"] == 41238711
assert stored["n"] == "03 - A Track.flac"
assert stored["g"] == "g1" and stored["hv"] == 2
def test_live_objects_are_never_stored(tmp_path, module_source):
"""The entries the views hand over carry a transport and a CryptoKey,
attached by the Search page's merge. Storing one is at best unserialisable
and at worst a dead connection read back a week later."""
body = """
const entry = { id:'abc', name:'x.flac', size:1, path:'p', type:'audio',
groupId:'g9', _tRef:{live:'transport'}, _gRef:{key:true},
_origPath:'elsewhere', _sources:[{groupId:'g9'}] };
const stored = toStored(entry, 'g1');
console.log(JSON.stringify({ keys: Object.keys(stored).sort(),
g: stored.g, json: JSON.stringify(stored) }));
"""
out = _run(tmp_path, module_source, body)
assert out["keys"] == sorted(["id", "g", "hv", "n", "s", "p", "t", "a", "b", "d", "tn"])
assert out["g"] == "g9", "the entry's own group must win over the caller's"
for leak in ("_tRef", "_gRef", "_origPath", "_sources", "transport"):
assert leak not in out["json"]
def test_a_stored_track_round_trips_to_what_the_player_consumes(
tmp_path, module_source):
body = """
const entry = { id:'abc', name:'03 - A Track.flac', size:99, path:'A/B',
type:'audio', duration:214, display_title:'A Track',
artist:'Artist', album:'Album', track_no:3,
hash_version:1, groupId:'g1' };
console.log(JSON.stringify(fromStored(toStored(entry, 'g1'))));
"""
out = _run(tmp_path, module_source, body)
for field in ("id", "name", "size", "path", "duration", "display_title",
"artist", "album", "track_no", "hash_version"):
assert out[field] == {**{"display_title": "A Track"},
**{"id": "abc", "name": "03 - A Track.flac",
"size": 99, "path": "A/B", "duration": 214,
"artist": "Artist", "album": "Album",
"track_no": 3, "hash_version": 1}}[field]
assert out["groupId"] == "g1"
# ── repair ───────────────────────────────────────────────────────────────────
def test_a_moved_file_is_found_by_its_hash_and_its_path_rewritten(
tmp_path, module_source):
"""Content addressing survives a move."""
body = """
const index = indexTracks([
{id:'t1', type:'audio', path:'New/Place', name:'a.flac'}]);
const { tracks, repaired } = repairTracks(
[{id:'t1', g:'g1', p:'Old/Place', n:'a.flac'}], 'g1', index);
console.log(JSON.stringify({tracks, repaired}));
"""
out = _run(tmp_path, module_source, body)
assert out["repaired"] == 1
assert out["tracks"][0]["p"] == "New/Place"
def test_a_re_encoded_file_is_found_by_its_path_and_its_hash_rewritten(
tmp_path, module_source):
"""A path survives a re-encode. Either field can repair the other, which is
the whole reason both are stored."""
body = """
const index = indexTracks([
{id:'t1-new', type:'audio', path:'A/B', name:'a.flac'}]);
const { tracks, repaired } = repairTracks(
[{id:'t1-old', g:'g1', p:'A/B', n:'a.flac'}], 'g1', index);
console.log(JSON.stringify({tracks, repaired}));
"""
out = _run(tmp_path, module_source, body)
assert out["repaired"] == 1
assert out["tracks"][0]["id"] == "t1-new"
def test_entries_from_other_groups_are_left_alone(tmp_path, module_source):
""""Not in the index I happen to be holding" is not evidence that a track
is gone — it is evidence about a different group."""
body = """
const index = indexTracks([{id:'x', type:'audio', path:'A', name:'a.flac'}]);
const { tracks, repaired } = repairTracks(
[{id:'t1', g:'g2', p:'A', n:'a.flac'}], 'g1', index);
console.log(JSON.stringify({tracks, repaired}));
"""
out = _run(tmp_path, module_source, body)
assert out["repaired"] == 0
assert out["tracks"][0]["id"] == "t1"
def test_an_entry_that_is_simply_gone_is_left_as_it_is(tmp_path, module_source):
"""Greyed at play time, not deleted from the playlist: a file that is
missing today may be a disk that is unplugged today."""
body = """
const index = indexTracks([{id:'other', type:'audio', path:'Z', name:'z.flac'}]);
const { tracks, repaired } = repairTracks(
[{id:'t1', g:'g1', p:'A', n:'a.flac'}], 'g1', index);
console.log(JSON.stringify({tracks, repaired}));
"""
out = _run(tmp_path, module_source, body)
assert out["repaired"] == 0 and out["tracks"][0]["id"] == "t1"
# ── favourites ───────────────────────────────────────────────────────────────
def test_favourites_is_first_whatever_it_is_called(tmp_path, module_source):
"""The menus promise it first, and the reserved id is what is stored — the
localised name is never written, or an account that switches language
grows a second favourites list."""
m = _manifest(zzz=_entry("Zzz", 1, "d"),
aaa=_entry("Aaa", 1, "d"),
favorites=_entry("Favoris", 1, "d"))
live = _eval(tmp_path, module_source, f"livePlaylists({json.dumps(m)})")
assert [p["id"] for p in live] == ["favorites", "aaa", "zzz"]
def test_the_reserved_id_is_a_constant_not_a_literal(tmp_path, module_source):
assert _eval(tmp_path, module_source, "FAVORITES_ID") == "favorites"
assert _eval(tmp_path, module_source, "bodyKind('favorites')") == "playlist:favorites"
assert _eval(tmp_path, module_source, "MANIFEST_KIND") == "playlists"
|