1# coding: utf-8
2
3# Copyright (c) Jupyter Development Team.
4# Distributed under the terms of the Modified BSD License.
5
6import pytest
7import nbformat
8from nbformat.v4 import new_notebook, new_code_cell
9from collections import defaultdict
10
11from nbdime import merge_notebooks, diff
12from nbdime.diff_format import op_patch
13from nbdime.utils import Strategies
14from nbdime.merging.generic import decide_merge, decide_merge_with_diff
15from nbdime.merging.decisions import apply_decisions
16from nbdime.merging.strategies import _cell_marker_format
17
18from .utils import outputs_to_notebook, sources_to_notebook, strip_cell_ids, strip_cell_id
19
20
21def test_decide_merge_strategy_fail(reset_log):
22    """Check that "fail" strategy results in proper exception raised."""
23    # One level dict
24    base = {"foo": 1}
25    local = {"foo": 2}
26    remote = {"foo": 3}
27    strategies = Strategies({"/foo": "fail"})
28    with pytest.raises(RuntimeError):
29        # pylint: disable=unused-variable
30        conflicted_decisions = decide_merge(base, local, remote, strategies)
31
32    # Nested dicts
33    base = {"foo": {"bar": 1}}
34    local = {"foo": {"bar": 2}}
35    remote = {"foo": {"bar": 3}}
36    strategies = Strategies({"/foo/bar": "fail"})
37    with pytest.raises(RuntimeError):
38        # pylint: disable=unused-variable
39        decisions = decide_merge(base, local, remote, strategies)
40
41    # We don't need this for non-leaf nodes and it's currently not implemented
42    # strategies = Strategies({"/foo": "fail"})
43    # with pytest.raises(RuntimeError):
44    #     decisions = decide_merge(base, local, remote, strategies)
45
46
47def test_decide_merge_strategy_clear1():
48    """Check strategy "clear" in various cases."""
49    # One level dict, clearing item value (think foo==execution_count)
50    base = {"foo": 1}
51    local = {"foo": 2}
52    remote = {"foo": 3}
53    strategies = Strategies({"/foo": "clear"})
54    decisions = decide_merge(base, local, remote, strategies)
55    assert apply_decisions(base, decisions) == {"foo": None}
56    assert not any([d.conflict for d in decisions])
57
58def test_decide_merge_strategy_clear2():
59    base = {"foo": "1"}
60    local = {"foo": "2"}
61    remote = {"foo": "3"}
62    strategies = Strategies({"/foo": "clear"})
63    decisions = decide_merge(base, local, remote, strategies)
64    #assert decisions == []
65    assert apply_decisions(base, decisions) == {"foo": ""}
66    assert not any([d.conflict for d in decisions])
67
68    # We don't need this for non-leaf nodes and it's currently not implemented
69    # base = {"foo": [1]}
70    # local = {"foo": [2]}
71    # remote = {"foo": [3]}
72    # strategies = Strategies({"/foo": "clear"})
73    # decisions = decide_merge(base, local, remote, strategies)
74    # assert apply_decisions(base, decisions) == {"foo": []}
75    # assert not any([d.conflict for d in decisions])
76
77
78def test_decide_merge_strategy_clear_all():
79    base = {"foo": [1, 2]}
80    local = {"foo": [1, 4, 2]}
81    remote = {"foo": [1, 3, 2]}
82
83    strategies = Strategies({"/foo": "clear-all"})
84    decisions = decide_merge(base, local, remote, strategies)
85    assert apply_decisions(base, decisions) == {"foo": []}
86
87    base = {"foo": [1, 2]}
88    local = {"foo": [1, 4, 2]}
89    remote = {"foo": [1, 2, 3]}
90
91    strategies = Strategies({"/foo": "clear-all"})
92    decisions = decide_merge(base, local, remote, strategies)
93    assert apply_decisions(base, decisions) == {"foo": [1, 4, 2, 3]}
94
95
96def test_decide_merge_strategy_remove():
97    base = {"foo": [1, 2]}
98    local = {"foo": [1, 4, 2]}
99    remote = {"foo": [1, 3, 2]}
100
101    strategies = Strategies({"/foo": "remove"})
102    decisions = decide_merge(base, local, remote, strategies)
103    assert apply_decisions(base, decisions) == {"foo": [1, 2]}
104    assert decisions[0].local_diff != []
105    assert decisions[0].remote_diff != []
106
107    strategies = Strategies({})
108    decisions = decide_merge(base, local, remote, strategies)
109    assert apply_decisions(base, decisions) == {"foo": [1, 2]}
110    assert decisions[0].local_diff != []
111    assert decisions[0].remote_diff != []
112
113
114def test_decide_merge_strategy_use_foo_on_dict_items():
115    base = {"foo": 1}
116    local = {"foo": 2}
117    remote = {"foo": 3}
118
119    strategies = Strategies({"/foo": "use-base"})
120    decisions = decide_merge(base, local, remote, strategies)
121    assert not any([d.conflict for d in decisions])
122    assert apply_decisions(base, decisions) == {"foo": 1}
123
124    strategies = Strategies({"/foo": "use-local"})
125    decisions = decide_merge(base, local, remote, strategies)
126    assert not any([d.conflict for d in decisions])
127    assert apply_decisions(base, decisions) == {"foo": 2}
128
129    strategies = Strategies({"/foo": "use-remote"})
130    decisions = decide_merge(base, local, remote, strategies)
131    assert not any([d.conflict for d in decisions])
132    assert apply_decisions(base, decisions) == {"foo": 3}
133
134    base = {"foo": {"bar": 1}}
135    local = {"foo": {"bar": 2}}
136    remote = {"foo": {"bar": 3}}
137
138    strategies = Strategies({"/foo/bar": "use-base"})
139    decisions = decide_merge(base, local, remote, strategies)
140    assert not any([d.conflict for d in decisions])
141    assert apply_decisions(base, decisions) == {"foo": {"bar": 1}}
142
143    strategies = Strategies({"/foo/bar": "use-local"})
144    decisions = decide_merge(base, local, remote, strategies)
145    assert not any([d.conflict for d in decisions])
146    assert apply_decisions(base, decisions) == {"foo": {"bar": 2}}
147
148    strategies = Strategies({"/foo/bar": "use-remote"})
149    decisions = decide_merge(base, local, remote, strategies)
150    assert not any([d.conflict for d in decisions])
151    assert apply_decisions(base, decisions) == {"foo": {"bar": 3}}
152
153
154def test_decide_merge_simple_list_insert_conflict_resolution():
155    # local and remote adds an entry each
156    b = [1]
157    l = [1, 2]
158    r = [1, 3]
159
160    strategies = Strategies({"/*": "use-local"})
161    decisions = decide_merge(b, l, r, strategies)
162    assert apply_decisions(b, decisions) == l
163    assert not any(d.conflict for d in decisions)
164
165    strategies = Strategies({"/*": "use-remote"})
166    decisions = decide_merge(b, l, r, strategies)
167    assert apply_decisions(b, decisions) == r
168    assert not any(d.conflict for d in decisions)
169
170    strategies = Strategies({"/*": "use-base"})
171    decisions = decide_merge(b, l, r, strategies)
172    assert apply_decisions(b, decisions) == b
173    assert not any(d.conflict for d in decisions)
174
175    strategies = Strategies({"/": "clear-all"})
176    decisions = decide_merge(b, l, r, strategies)
177    assert apply_decisions(b, decisions) == []
178    assert not any(d.conflict for d in decisions)
179
180@pytest.mark.skip
181def test_decide_merge_simple_list_insert_conflict_resolution__union():
182    # local and remote adds an entry each
183    b = [1]
184    l = [1, 2]
185    r = [1, 3]
186
187    strategies = Strategies({"/": "union"})
188    decisions = decide_merge(b, l, r, strategies)
189    assert apply_decisions(b, decisions) == [1, 2, 3]
190    assert not any(d.conflict for d in decisions)
191
192
193def test_decide_merge_list_conflicting_insertions_separate_chunks_v1():
194    # local and remote adds an equal entry plus a different entry each
195    # First, test when insertions DO NOT chunk together:
196    b = [1, 9]
197    l = [1, 2, 9, 11]
198    r = [1, 3, 9, 11]
199
200    # Check strategyless resolution
201    strategies = Strategies({})
202    resolved = decide_merge(b, l, r, strategies)
203    expected_partial = [1, 9, 11]
204    assert apply_decisions(b, resolved) == expected_partial
205    assert len(resolved) == 2
206    assert resolved[0].conflict
207    assert not resolved[1].conflict
208
209    strategies = Strategies({"/*": "use-local"})
210    resolved = decide_merge(b, l, r, strategies)
211    assert apply_decisions(b, resolved) == l
212    assert not any(d.conflict for d in resolved)
213
214    strategies = Strategies({"/*": "use-remote"})
215    resolved = decide_merge(b, l, r, strategies)
216    assert apply_decisions(b, resolved) == r
217    assert not any(d.conflict for d in resolved)
218
219    strategies = Strategies({"/*": "use-base"})
220    resolved = decide_merge(b, l, r, strategies)
221    # Strategy is only applied to conflicted decisions:
222    assert apply_decisions(b, resolved) == expected_partial
223    assert not any(d.conflict for d in resolved)
224
225    strategies = Strategies({"/": "clear-all"})
226    resolved = decide_merge(b, l, r, strategies)
227    assert apply_decisions(b, resolved) == []
228    assert not any(d.conflict for d in resolved)
229
230    # from _merge_concurrent_inserts:
231    # FIXME: This function doesn't work out so well with new conflict handling,
232    # when an insert (e.g. [2,7] vs [3,7]) gets split into agreement on [7] and
233    # conflict on [2] vs [3], the ordering gets lost. I think this was always
234    # slightly ambiguous in the decision format, as the new inserts will get
235    # the same key and decisions are supposed to be possible to reorder (sort)
236    # without considering original ordering of decisions. To preserve the
237    # ordering, perhaps we can add relative local/remote indices to the decisions?
238    # We had this, where ordering made it work out correctly:
239    #   "conflicting insert [2] vs [3] at 1 (base index);
240    #    insert [7] at 1 (base index)"
241    # instead we now have this which messes up the ordering:
242    #   "insert [7] at 1 (base index);
243    #    conflicting insert [2] vs [3] at 1 (base index)"
244    # perhaps change to this:
245    #   "insert [7] at key=1 (base index) lkey=1 rkey=1;
246    #    conflicting insert [2] vs [3] at key=1 (base index) lkey=0 rkey=0"
247    # then decisions can be sorted on (key,lkey) or (key,rkey) depending on chosen side.
248    # This test covers the behaviour:
249    # py.test -k test_shallow_merge_lists_insert_conflicted -s -vv
250    #DEBUGGING = 1
251    #if DEBUGGING: import ipdb; ipdb.set_trace()
252
253    # Example:
254    # b  l  r
255    # 1  a  x
256    # 2  b  y
257    # 3  c  3
258    # 4  4  4
259    # Diffs:
260    # b/l: insert a, b, c; remove 1-3
261    # b/r: insert x, y; remove 1-2
262    # The current chunking splits the removes here:
263    # [insert a, b, c; remove 1-2]; [remove 3]
264    # [insert x, y; remove 1-2]
265    # That results in remove 3 not being conflicted.
266
267def test_decide_merge_list_conflicting_insertions_separate_chunks_v2():
268    # local and remote adds an equal entry plus a different entry each
269    # First, test when insertions DO NOT chunk together:
270    b = [1, 9]
271    l = [1, 2, 9, 11]
272    r = [1, 3, 9, 11]
273
274    # Check strategyless resolution
275    strategies = Strategies({})
276    resolved = decide_merge(b, l, r, strategies)
277    expected_partial = [1, 9, 11]
278    assert apply_decisions(b, resolved) == expected_partial
279    assert len(resolved) == 2
280    assert resolved[0].conflict
281    assert not resolved[1].conflict
282
283
284@pytest.mark.skip
285def test_decide_merge_list_conflicting_insertions_separate_chunks__union():
286    # local and remote adds an equal entry plus a different entry each
287    # First, test when insertions DO NOT chunk together:
288    b = [1, 9]
289    l = [1, 2, 9, 11]
290    r = [1, 3, 9, 11]
291
292    strategies = Strategies({"/": "union"})
293    resolved = decide_merge(b, l, r, strategies)
294    assert apply_decisions(b, resolved) == [1, 2, 3, 9, 11]
295    assert not any(d.conflict for d in resolved)
296
297
298def test_decide_merge_list_conflicting_insertions_in_chunks():
299    # Next, test when insertions DO chunk together:
300    b = [1, 9]
301    l = [1, 2, 7, 9]
302    r = [1, 3, 7, 9]
303
304    # Check strategyless resolution
305    strategies = Strategies({})
306    resolved = decide_merge(b, l, r, strategies)
307    expected_partial = [1, 7, 9]
308    assert apply_decisions(b, resolved) == expected_partial
309
310    strategies = Strategies({"/*": "use-local"})
311    resolved = decide_merge(b, l, r, strategies)
312    assert apply_decisions(b, resolved) == l
313    assert not any(d.conflict for d in resolved)
314
315    strategies = Strategies({"/*": "use-remote"})
316    resolved = decide_merge(b, l, r, strategies)
317    assert apply_decisions(b, resolved) == r
318    assert not any(d.conflict for d in resolved)
319
320    strategies = Strategies({"/*": "use-base"})
321    resolved = decide_merge(b, l, r, strategies)
322    assert apply_decisions(b, resolved) == expected_partial
323    assert not any(d.conflict for d in resolved)
324
325    strategies = Strategies({"/": "clear-all"})
326    resolved = decide_merge(b, l, r, strategies)
327    assert apply_decisions(b, resolved) == []
328    assert not any(d.conflict for d in resolved)
329
330
331@pytest.mark.skip
332def test_decide_merge_list_conflicting_insertions_in_chunks__union():
333    # Next, test when insertions DO chunk together:
334    b = [1, 9]
335    l = [1, 2, 7, 9]
336    r = [1, 3, 7, 9]
337
338    strategies = Strategies({"/": "union"})
339    resolved = decide_merge(b, l, r, strategies)
340    assert apply_decisions(b, resolved) == [1, 2, 3, 7, 9]
341    assert not any(d.conflict for d in resolved)
342
343
344def test_decide_merge_list_transients():
345    # For this test, we need to use a custom predicate to ensure alignment
346    common = {'id': 'This ensures alignment'}
347    predicates = defaultdict(lambda: [operator.__eq__], {
348        '/': [lambda a, b: a['id'] == b['id']],
349    })
350
351    # Setup transient difference in base and local, deletion in remote
352    b = [{'transient': 22}]
353    l = [{'transient': 242}]
354    b[0].update(common)
355    l[0].update(common)
356    r = []
357
358    # Make decisions based on diffs with predicates
359    ld = diff(b, l, path="", predicates=predicates)
360    rd = diff(b, r, path="", predicates=predicates)
361
362    # Assert that generic merge without strategies gives conflict:
363    strategies = Strategies()
364    decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies)
365    assert len(decisions) == 1
366    assert decisions[0].conflict
367    assert apply_decisions(b, decisions) == b
368
369    # Supply transient list to autoresolve, and check that transient is ignored
370    strategies = Strategies(transients=[
371        '/*/transient'
372    ])
373    decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies)
374    assert apply_decisions(b, decisions) == r
375    assert not any(d.conflict for d in decisions)
376
377
378def test_decide_merge_dict_transients():
379    # Setup transient difference in base and local, deletion in remote
380    b = {'a': {'transient': 22}}
381    l = {'a': {'transient': 242}}
382    r = {}
383
384    # Assert that generic merge gives conflict
385    strategies = Strategies()
386    decisions = decide_merge(b, l, r, strategies)
387    assert apply_decisions(b, decisions) == b
388    assert len(decisions) == 1
389    assert decisions[0].conflict
390
391    # Supply transient list to autoresolve, and check that transient is ignored
392    strategies = Strategies(transients=[
393        '/a/transient'
394    ])
395    decisions = decide_merge(b, l, r, strategies)
396    assert apply_decisions(b, decisions) == r
397    assert not any(d.conflict for d in decisions)
398
399
400def test_decide_merge_mixed_nested_transients():
401    # For this test, we need to use a custom predicate to ensure alignment
402    common = {'id': 'This ensures alignment'}
403    predicates = defaultdict(lambda: [operator.__eq__], {
404        '/': [lambda a, b: a['id'] == b['id']],
405    })
406    # Setup transient difference in base and local, deletion in remote
407    b = [{'a': {'transient': 22}}]
408    l = [{'a': {'transient': 242}}]
409    b[0].update(common)
410    l[0].update(common)
411    r = []
412
413    # Make decisions based on diffs with predicates
414    ld = diff(b, l, path="", predicates=predicates)
415    rd = diff(b, r, path="", predicates=predicates)
416
417    # Assert that generic merge gives conflict
418    strategies = Strategies()
419    decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies)
420    assert apply_decisions(b, decisions) == b
421    assert len(decisions) == 1
422    assert decisions[0].conflict
423
424    # Supply transient list to autoresolve, and check that transient is ignored
425    strategies = Strategies(transients=[
426        '/*/a/transient'
427    ])
428    decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies)
429    assert apply_decisions(b, decisions) == r
430    assert not any(d.conflict for d in decisions)
431
432
433def test_inline_merge_empty_notebooks():
434    "Missing fields all around passes through."
435    base = {}
436    local = {}
437    remote = {}
438    expected = {}
439    merged, decisions = merge_notebooks(base, local, remote)
440    assert expected == merged
441
442
443def test_inline_merge_dummy_notebooks():
444    "Just the basic empty notebook passes through."
445    base = new_notebook()
446    local = new_notebook()
447    remote = new_notebook()
448    expected = new_notebook()
449    merged, decisions = merge_notebooks(base, local, remote)
450    assert expected == merged
451
452
453def test_inline_merge_notebook_version():
454    "Minor version gets bumped to max."
455    base = new_notebook(nbformat=4, nbformat_minor=0)
456    local = new_notebook(nbformat=4, nbformat_minor=1)
457    remote = new_notebook(nbformat=4, nbformat_minor=2)
458    expected = new_notebook(nbformat=4, nbformat_minor=2)
459    merged, decisions = merge_notebooks(base, local, remote)
460    assert expected == merged
461
462
463def test_inline_merge_notebook_metadata(reset_log):
464    """Merging a wide range of different value types
465    and conflict types in the root /metadata dicts.
466    The goal is to exercise a decent part of the
467    generic diff and merge functionality.
468    """
469
470    untouched = {
471        "string": "untouched string",
472        "integer": 123,
473        "float": 16.0,
474        "list": ["hello", "world"],
475        "dict": {"first": "Hello", "second": "World"},
476    }
477    md_in = {
478        1: {
479            "untouched": untouched,
480            "unconflicted": {
481                "int_deleteme": 7,
482                "string_deleteme": "deleteme",
483                "list_deleteme": [7, "deleteme"],
484                "dict_deleteme": {"deleteme": "now", "removeme": True},
485                "list_deleteitem": [7, "deleteme", 3, "notme", 5, "deletemetoo"],
486
487                "string": "string v1",
488                "integer": 456,
489                "float": 32.0,
490                "list": ["hello", "universe"],
491                "dict": {"first": "Hello", "second": "World", "third": "!"},
492            },
493            "conflicted": {
494                "int_delete_replace": 3,
495                "string_delete_replace": "string that will be deleted and modified",
496                "list_delete_replace": [1],
497                "dict_delete_replace": {"k":"v"},
498
499            #     "string": "string v1",
500            #     "integer": 456,
501            #     "float": 32.0,
502            #     "list": ["hello", "universe"],
503            #     "dict": {"first": "Hello", "second": "World"},
504            }
505        },
506        2: {
507            "untouched": untouched,
508            "unconflicted": {
509                "dict_deleteme": {"deleteme": "now", "removeme": True},
510                "list_deleteitem": [7, 3, "notme", 5, "deletemetoo"],
511
512                "string": "string v1 equal addition",
513                "integer": 123, # equal change
514                "float": 16.0, # equal change
515                # Equal delete at beginning and insert of two values at end:
516                "list": ["universe", "new items", "same\non\nboth\nsides"],
517                # cases covered: twosided equal value change, onesided delete, onesided replace, onesided insert, twosided insert of same value
518                "dict": {"first": "changed", "second": "World", "third": "!", "newkey": "newvalue", "otherkey": "othervalue"},
519            },
520            "conflicted": {
521                "int_delete_replace": 5,
522                "list_delete_replace": [2],
523
524                # "string": "another text",
525                 #"integer": 456,
526            #     "float": 16.0,
527            #     "list": ["hello", "world"],
528            #     "dict": {"new": "value", "first": "Hello"}, #"second": "World"},
529
530            #     "added_string": "another text",
531            #     "added_integer": 9,
532            #     "added_float": 16.0,
533            #     "added_list": ["another", "multiverse"],
534            #     "added_dict": {"1st": "hey", "2nd": "there"},
535            }
536        },
537        3: {
538            "untouched": untouched,
539            "unconflicted": {
540                "list_deleteme": [7, "deleteme"],
541                "list_deleteitem": [7, "deleteme", 3, "notme", 5],
542
543                "string": "string v1 equal addition",
544                "integer": 123, # equal change
545                "float": 16.0, # equal change
546                # Equal delete at beginning and insert of two values at end:
547                "list": ["universe", "new items", "same\non\nboth\nsides"],
548                "dict": {"first": "changed", "third": ".", "newkey": "newvalue"},
549            },
550            "conflicted": {
551                "string_delete_replace": "string that is modified here and deleted in the other version",
552                "dict_delete_replace": {"k":"x","q":"r"},
553
554            #     "string": "different message",
555            #     "integer": 456,
556            #     #"float": 16.0,
557            #     "list": ["hello", "again", "world"],
558            #     "dict": {"new": "but different", "first": "Hello"}, #"second": "World"},
559
560            #     "added_string": "but not the same string",
561            #     #"added_integer": 9,
562            #     "added_float": 64.0,
563            #     "added_list": ["initial", "values", "another", "multiverse", "trailing", "values"],
564            #     "added_dict": {"3rt": "mergeme", "2nd": "conflict"},
565            }
566        }
567    }
568
569    def join_dicts(dicta, dictb):
570        d = {}
571        d.update(dicta)
572        d.update(dictb)
573        return d
574
575    shared_unconflicted = {
576        "list_deleteitem": [7, 3, "notme", 5],
577
578        "string": "string v1 equal addition",
579        "integer": 123,
580        "float": 16.0,
581        "list": ["universe", "new items", "same\non\nboth\nsides"],
582        "dict": {"first": "changed", "third": ".",  "newkey": "newvalue", "otherkey": "othervalue"},
583    }
584    shared_conflicted = {
585        "int_delete_replace": 3,
586        "string_delete_replace": "string that will be deleted and modified",
587        "list_delete_replace": [1],
588        "dict_delete_replace": {"k":"v"},
589
590    #     #"string": "string v1",
591    #     "string": "another textdifferent message",
592
593    #     "float": 32.0,
594    #     "list": ["hello", "universe"],
595    #     "dict": {"first": "Hello", "second": "World"},
596    #     # FIXME
597    }
598
599    md_out = {
600        (1,2,3): {
601            "untouched": untouched,
602            "unconflicted": join_dicts(shared_unconflicted, {
603                # ...
604            }),
605            "conflicted": join_dicts(shared_conflicted, {
606                # ...
607            }),
608        },
609        (1,3,2): {
610            "untouched": untouched,
611            "unconflicted": join_dicts(shared_unconflicted, {
612                # ...
613            }),
614            "conflicted": join_dicts(shared_conflicted, {
615                # ...
616            }),
617        },
618    }
619
620    # Fill in expected conflict records
621    for triplet in sorted(md_out.keys()):
622        i, j, k = triplet
623        local_diff = diff(md_in[i]["conflicted"], md_in[j]["conflicted"])
624        remote_diff = diff(md_in[i]["conflicted"], md_in[k]["conflicted"])
625
626        # This may not be a necessary test, just checking my expectations
627        assert local_diff == sorted(local_diff, key=lambda x: x.key)
628        assert remote_diff == sorted(remote_diff, key=lambda x: x.key)
629
630        c = {
631            # These are patches on the /metadata dict
632            "local_diff": [op_patch("conflicted", local_diff)],
633            "remote_diff": [op_patch("conflicted", remote_diff)],
634        }
635        md_out[triplet]["nbdime-conflicts"] = c
636
637    # Fill in the trivial merge results
638    for i in (1, 2, 3):
639        for j in (1, 2, 3):
640            for k in (i, j):
641                # For any combination i,j,i or i,j,j the
642                # result should be j with no conflicts
643                md_out[(i,j,k)] = md_in[j]
644
645    tested = set()
646    # Check the trivial merge results
647    for i in (1, 2, 3):
648        for j in (1, 2, 3):
649            for k in (i, j):
650                triplet = (i, j, k)
651                tested.add(triplet)
652                base = new_notebook(metadata=md_in[i])
653                local = new_notebook(metadata=md_in[j])
654                remote = new_notebook(metadata=md_in[k])
655                # For any combination i,j,i or i,j,j the result should be j
656                expected = new_notebook(metadata=md_in[j])
657                merged, decisions = merge_notebooks(base, local, remote)
658                assert "nbdime-conflicts" not in merged["metadata"]
659                assert not any([d.conflict for d in decisions])
660                assert expected == merged
661
662    # Check handcrafted merge results
663    for triplet in sorted(md_out.keys()):
664        i, j, k = triplet
665        tested.add(triplet)
666        base = new_notebook(metadata=md_in[i])
667        local = new_notebook(metadata=md_in[j])
668        remote = new_notebook(metadata=md_in[k])
669        expected = new_notebook(metadata=md_out[triplet])
670        merged, decisions = merge_notebooks(base, local, remote)
671        if "nbdime-conflicts" in merged["metadata"]:
672            assert any([d.conflict for d in decisions])
673        else:
674            assert not any([d.conflict for d in decisions])
675        assert expected == merged
676
677    # At least try to run merge without crashing for permutations
678    # of md_in that we haven't constructed expected results for
679    for i in (1, 2, 3):
680        for j in (1, 2, 3):
681            for k in (1, 2, 3):
682                triplet = (i, j, k)
683                if triplet not in tested:
684                    base = new_notebook(metadata=md_in[i])
685                    local = new_notebook(metadata=md_in[j])
686                    remote = new_notebook(metadata=md_in[k])
687                    merged, decisions = merge_notebooks(base, local, remote)
688
689
690def test_inline_merge_notebook_metadata_reproduce_bug(reset_log):
691    md_in = {
692        1: {
693            "unconflicted": {
694                "list_deleteitem": [7, "deleteme", 3, "notme", 5, "deletemetoo"],
695            },
696            "conflicted": {
697                "dict_delete_replace": {"k":"v"},
698            }
699        },
700        2: {
701            "unconflicted": {
702                "list_deleteitem": [7, 3, "notme", 5, "deletemetoo"],
703            },
704            "conflicted": {
705            }
706        },
707        3: {
708            "unconflicted": {
709                "list_deleteitem": [7, "deleteme", 3, "notme", 5],
710            },
711            "conflicted": {
712                "dict_delete_replace": {"k":"x"},
713            }
714        }
715    }
716
717    shared_unconflicted = {
718        "list_deleteitem": [7, 3, "notme", 5],
719    }
720    shared_conflicted = {
721        "dict_delete_replace": {"k":"v"},
722    }
723
724    md_out = {
725        (1,2,3): {
726            "unconflicted": shared_unconflicted,
727            "conflicted": shared_conflicted
728        },
729    }
730
731    # Fill in expected conflict records
732    for triplet in sorted(md_out.keys()):
733        i, j, k = triplet
734        local_diff = diff(md_in[i]["conflicted"], md_in[j]["conflicted"])
735        remote_diff = diff(md_in[i]["conflicted"], md_in[k]["conflicted"])
736
737        # This may not be a necessary test, just checking my expectations
738        assert local_diff == sorted(local_diff, key=lambda x: x.key)
739        assert remote_diff == sorted(remote_diff, key=lambda x: x.key)
740
741        c = {
742            # These are patches on the /metadata dict
743            "local_diff": [op_patch("conflicted", local_diff)],
744            "remote_diff": [op_patch("conflicted", remote_diff)],
745        }
746        md_out[triplet]["nbdime-conflicts"] = c
747
748    # Check handcrafted merge results
749    triplet = (1,2,3)
750    i, j, k = triplet
751    base = new_notebook(metadata=md_in[i])
752    local = new_notebook(metadata=md_in[j])
753    remote = new_notebook(metadata=md_in[k])
754    expected = new_notebook(metadata=md_out[triplet])
755    merged, decisions = merge_notebooks(base, local, remote)
756    if "nbdime-conflicts" in merged["metadata"]:
757        assert any([d.conflict for d in decisions])
758    else:
759        assert not any([d.conflict for d in decisions])
760    assert expected == merged
761
762
763def test_inline_merge_source_empty():
764    base = new_notebook()
765    local = new_notebook()
766    remote = new_notebook()
767    expected = new_notebook()
768    merged, decisions = merge_notebooks(base, local, remote)
769    assert merged == expected
770
771
772def code_nb(sources, strip_ids=False):
773    nb = new_notebook(cells=[new_code_cell(s) for s in sources])
774    strip_cell_ids(nb)
775    return nb
776
777
778def test_inline_merge_source_all_equal():
779    base = code_nb([
780        "first source",
781        "other text",
782        "yet more content",
783    ])
784    local = base
785    remote = base
786    expected = base
787    merged, decisions = merge_notebooks(base, local, remote)
788    assert merged == expected
789
790
791def test_inline_merge_source_cell_deletions():
792    "Cell deletions on both sides, onesided and agreed."
793    base = code_nb([
794        "first source",
795        "other text",
796        "yet more content",
797        "and a final line",
798        ])
799    local = code_nb([
800        #"first source",
801        "other text",
802        #"yet more content",
803        #"and a final line",
804        ])
805    remote = code_nb([
806        "first source",
807        #"other text",
808        "yet more content",
809        #"and a final line",
810        ])
811    empty = code_nb([])
812    for a in [base, local, remote, empty]:
813        for b in [base, local, remote, empty]:
814            merged, decisions = merge_notebooks(base, a, b)
815            if a is b:
816                assert merged == a
817            elif a is base:
818                assert merged == b
819            elif b is base:
820                assert merged == a
821            else:
822                # All other combinations will delete all cells
823                assert merged == empty
824
825
826def test_inline_merge_source_onesided_only():
827    "A mix of changes on one side (delete, patch, remove)."
828    base = code_nb([
829        "first source",
830        "other text",
831        "yet more content",
832        ])
833    changed = code_nb([
834        #"first source", # deleted
835        "other text v2",
836        "a different cell inserted",
837        "yet more content",
838        ])
839    merged, decisions = merge_notebooks(base, changed, base)
840    assert merged == changed
841    merged, decisions = merge_notebooks(base, base, changed)
842    assert merged == changed
843
844
845def test_inline_merge_source_replace_line():
846    "More elaborate test of cell deletions on both sides, onesided and agreed."
847    # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability
848    base = code_nb([
849        "first source",
850        "other text",
851        "this cell will be deleted and patched",
852        "yet more content",
853        "and a final line",
854        ], strip_ids=True)
855    local = code_nb([
856        "1st source",  # onesided change
857        "other text",
858        #"this cell will be deleted and patched",
859        "some more content",  # twosided equal change
860        "And a Final line",  # twosided conflicted change
861        ], strip_ids=True)
862    remote = code_nb([
863        "first source",
864        "other text?",  # onesided change
865        "this cell will be deleted and modified",
866        "some more content",   # equal
867        "and The final Line",  # conflicted
868        ], strip_ids=True)
869    expected = code_nb([
870        "1st source",
871        "other text?",
872        #'<<<<<<< local <CELL DELETED>\n\n=======\nthis cell will be deleted and modified\n>>>>>>> remote'
873        '<<<<<<< LOCAL CELL DELETED >>>>>>>\nthis cell will be deleted and modified',
874        "some more content",  # equal
875        '<<<<<<< local\nAnd a Final line\n=======\nand The final Line\n>>>>>>> remote'
876        ], strip_ids=True)
877    merged, decisions = merge_notebooks(base, local, remote)
878    assert merged == expected
879    expected = code_nb([
880        "1st source",
881        "other text?",
882        #'<<<<<<< local\nthis cell will be deleted and modified\n=======\n>>>>>>> remote <CELL DELETED>'
883        '<<<<<<< REMOTE CELL DELETED >>>>>>>\nthis cell will be deleted and modified',
884        "some more content",
885        '<<<<<<< local\nand The final Line\n=======\nAnd a Final line\n>>>>>>> remote'
886        ], strip_ids=True)
887    merged, decisions = merge_notebooks(base, remote, local)
888    assert merged == expected
889
890
891def test_inline_merge_source_add_to_line():
892    "More elaborate test of cell deletions on both sides, onesided and agreed."
893    # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability
894    base = code_nb([
895        "first source",
896        "other text",
897        "this cell will be deleted and patched\nhere we add",
898        "yet more content",
899        "and a final line",
900        ], strip_ids=True)
901    local = code_nb([
902        "1st source",  # onesided change
903        "other text",
904        #"this cell will be deleted and patched",
905        "some more content",  # twosided equal change
906        "And a Final line",  # twosided conflicted change
907        ], strip_ids=True)
908    remote = code_nb([
909        "first source",
910        "other text?",  # onesided change
911        "this cell will be deleted and patched\nhere we add text to a line",
912        "some more content",   # equal
913        "and The final Line",  # conflicted
914        ], strip_ids=True)
915    expected = code_nb([
916        "1st source",
917        "other text?",
918        #'<<<<<<< local <CELL DELETED>\n\n=======\nthis cell will be deleted and modified\n>>>>>>> remote'
919        '<<<<<<< LOCAL CELL DELETED >>>>>>>\nthis cell will be deleted and patched\nhere we add text to a line',
920        "some more content",  # equal
921        '<<<<<<< local\nAnd a Final line\n=======\nand The final Line\n>>>>>>> remote'
922        ], strip_ids=True)
923    merged, decisions = merge_notebooks(base, local, remote)
924    assert merged == expected
925    expected = code_nb([
926        "1st source",
927        "other text?",
928        #'<<<<<<< local\nthis cell will be deleted and modified\n=======\n>>>>>>> remote <CELL DELETED>'
929        '<<<<<<< REMOTE CELL DELETED >>>>>>>\nthis cell will be deleted and patched\nhere we add text to a line',
930        "some more content",
931        '<<<<<<< local\nand The final Line\n=======\nAnd a Final line\n>>>>>>> remote'
932        ], strip_ids=True)
933    merged, decisions = merge_notebooks(base, remote, local)
934    assert merged == expected
935
936
937def test_inline_merge_source_patches_both_ends():
938    "More elaborate test of cell deletions on both sides, onesided and agreed."
939    # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability
940    base = code_nb([
941        "first source will be modified",
942        "other text",
943        "this cell will be untouched",
944        "yet more content",
945        "and final line will be changed",
946        ], strip_ids=True)
947    local = code_nb([
948        "first source will be modified locally",
949        "other text",
950        "this cell will be untouched",
951        "yet more content",
952        "and final line will be changed locally",
953        ], strip_ids=True)
954    remote = code_nb([
955        "first source will be modified remotely",
956        "other text",
957        "this cell will be untouched",
958        "yet more content",
959        "and final line will be changed remotely",
960        ], strip_ids=True)
961    expected = code_nb([
962        '<<<<<<< local\nfirst source will be modified locally\n=======\nfirst source will be modified remotely\n>>>>>>> remote',
963        "other text",
964        "this cell will be untouched",
965        "yet more content",
966        '<<<<<<< local\nand final line will be changed locally\n=======\nand final line will be changed remotely\n>>>>>>> remote',
967        ], strip_ids=True)
968    merged, decisions = merge_notebooks(base, local, remote)
969    assert merged == expected
970    expected = code_nb([
971        '<<<<<<< local\nfirst source will be modified remotely\n=======\nfirst source will be modified locally\n>>>>>>> remote',
972        "other text",
973        "this cell will be untouched",
974        "yet more content",
975        '<<<<<<< local\nand final line will be changed remotely\n=======\nand final line will be changed locally\n>>>>>>> remote',
976        ], strip_ids=True)
977    merged, decisions = merge_notebooks(base, remote, local)
978    assert merged == expected
979
980
981def test_inline_merge_source_patch_delete_conflicts_both_ends():
982    "More elaborate test of cell deletions on both sides, onesided and agreed."
983    # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability
984    base = code_nb([
985        "first source will be modified",
986        "other text",
987        "this cell will be untouched",
988        "yet more content",
989        "and final line will be changed",
990        ])
991    local = code_nb([
992        "first source will be modified on one side",
993        "other text",
994        "this cell will be untouched",
995        "yet more content",
996        #"and final line will be deleted locally",
997        ])
998    remote = code_nb([
999        #"first source will be deleted remotely",
1000        "other text",
1001        "this cell will be untouched",
1002        "yet more content",
1003        "and final line will be changed on one side",
1004        ])
1005    expected = code_nb([
1006        '<<<<<<< REMOTE CELL DELETED >>>>>>>\nfirst source will be modified on one side',
1007        "other text",
1008        "this cell will be untouched",
1009        "yet more content",
1010        '<<<<<<< LOCAL CELL DELETED >>>>>>>\nand final line will be changed on one side',
1011        ])
1012    merged, decisions = merge_notebooks(base, local, remote)
1013    assert merged == expected
1014    expected = code_nb([
1015        '<<<<<<< LOCAL CELL DELETED >>>>>>>\nfirst source will be modified on one side',
1016        "other text",
1017        "this cell will be untouched",
1018        "yet more content",
1019        '<<<<<<< REMOTE CELL DELETED >>>>>>>\nand final line will be changed on one side',
1020        ])
1021    merged, decisions = merge_notebooks(base, remote, local)
1022    assert merged == expected
1023
1024
1025def test_inline_merge_attachments():
1026    # FIXME: Use output creation utils Vidar wrote in another test file
1027    base = new_notebook()
1028    local = new_notebook()
1029    remote = new_notebook()
1030    expected = new_notebook()
1031    merged, decisions = merge_notebooks(base, local, remote)
1032    assert merged == expected
1033
1034
1035def test_inline_merge_outputs():
1036    # One cell with two outputs:
1037    base = outputs_to_notebook([['unmodified', 'base']], strip_ids=True)
1038    local = outputs_to_notebook([['unmodified', 'local']], strip_ids=True)
1039    remote = outputs_to_notebook([['unmodified', 'remote']], strip_ids=True)
1040    expected = outputs_to_notebook([[
1041        'unmodified',
1042        nbformat.v4.new_output(
1043            output_type='stream', name='stderr',
1044            text='<<<<<<< local <modified: text/plain>\n'),
1045        'local',
1046        nbformat.v4.new_output(
1047            output_type='stream', name='stderr',
1048            text='=======\n'),
1049        'remote',
1050        nbformat.v4.new_output(
1051            output_type='stream', name='stderr',
1052            text='>>>>>>> remote <modified: text/plain>\n'),
1053    ]], strip_ids=True)
1054    merged, decisions = merge_notebooks(base, local, remote)
1055    assert merged == expected
1056
1057
1058def test_inline_merge_outputs_conflicting_insert_in_empty():
1059    # One cell with two outputs:
1060    base = outputs_to_notebook([[]], strip_ids=True)
1061    local = outputs_to_notebook([['local']], strip_ids=True)
1062    remote = outputs_to_notebook([['remote']], strip_ids=True)
1063    expected = outputs_to_notebook([[
1064        nbformat.v4.new_output(
1065            output_type='stream', name='stderr',
1066            text='<<<<<<< local\n'),
1067        'local',
1068        nbformat.v4.new_output(
1069            output_type='stream', name='stderr',
1070            text='=======\n'),
1071        'remote',
1072        nbformat.v4.new_output(
1073            output_type='stream', name='stderr',
1074            text='>>>>>>> remote\n'),
1075    ]], strip_ids=True)
1076    merged, decisions = merge_notebooks(base, local, remote)
1077    assert merged == expected
1078
1079
1080def test_inline_merge_cells_insertion_similar():
1081    base = sources_to_notebook([['unmodified']], cell_type='markdown', strip_ids=True)
1082    local = sources_to_notebook([['unmodified'], ['local']], cell_type='markdown', strip_ids=True)
1083    remote = sources_to_notebook([['unmodified'], ['remote']], cell_type='markdown', strip_ids=True)
1084    expected = sources_to_notebook([
1085        'unmodified',
1086        [
1087            ("<"*7) + ' local\n',
1088            'local\n',
1089            ("="*7) + '\n',
1090            'remote\n',
1091            (">"*7) + ' remote'
1092        ]
1093    ], cell_type='markdown', strip_ids=True)
1094    merged, decisions = merge_notebooks(base, local, remote)
1095    assert merged == expected
1096
1097
1098def test_inline_merge_cells_insertion_unsimilar():
1099    base = sources_to_notebook([['unmodified']], cell_type='markdown', strip_ids=True)
1100    local = sources_to_notebook([['unmodified'], ['local\n', 'friendly faces\n', '3.14']], cell_type='markdown', strip_ids=True)
1101    remote = sources_to_notebook([['unmodified'], ['remote\n', 'foo bar baz\n']], cell_type='markdown', strip_ids=True)
1102    expected = sources_to_notebook([
1103        ['unmodified'],
1104        [_cell_marker_format(("<"*7) + ' local')],
1105        ['local\n', 'friendly faces\n', '3.14'],
1106        [_cell_marker_format("="*7)],
1107        ['remote\n', 'foo bar baz\n'],
1108        [_cell_marker_format((">"*7) + ' remote')],
1109    ], cell_type='markdown', strip_ids=True)
1110    merged, decisions = merge_notebooks(base, local, remote)
1111    assert strip_cell_ids(merged) == expected
1112
1113
1114def test_inline_merge_cells_replacement_similar():
1115    base = sources_to_notebook([['unmodified'], ['base']], cell_type='markdown', strip_ids=False)
1116    local = sources_to_notebook([['unmodified'], ['local']], cell_type='markdown', strip_ids=True)
1117    remote = sources_to_notebook([['unmodified'], ['remote']], cell_type='markdown', strip_ids=True)
1118    expected = sources_to_notebook([
1119        ['unmodified'],
1120        [
1121            ("<"*7) + ' local\n',
1122            'local\n',
1123            ("="*7) + '\n',
1124            'remote\n',
1125            (">"*7) + ' remote'
1126        ]
1127    ], cell_type='markdown', strip_ids=True)
1128    merged, decisions = merge_notebooks(base, local, remote)
1129    assert merged == expected
1130
1131
1132def test_inline_merge_cells_replacement_unsimilar():
1133    base = sources_to_notebook([['unmodified'], ['base']], cell_type='markdown', strip_ids=False)
1134    local = sources_to_notebook([['unmodified'], ['local\n', 'friendly faces\n', '3.14']], cell_type='markdown', strip_ids=True)
1135    remote = sources_to_notebook([['unmodified'], ['remote\n', 'foo bar baz\n']], cell_type='markdown', strip_ids=True)
1136    expected = sources_to_notebook([
1137        ['unmodified'],
1138        [_cell_marker_format(("<"*7) + ' local')],
1139        ['local\n', 'friendly faces\n', '3.14'],
1140        [_cell_marker_format("="*7)],
1141        ['remote\n', 'foo bar baz\n'],
1142        [_cell_marker_format((">"*7) + ' remote')],
1143    ], cell_type='markdown', strip_ids=True)
1144    merged, decisions = merge_notebooks(base, local, remote)
1145    assert strip_cell_ids(merged) == expected
1146