slidesync 0.2.0__tar.gz → 0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
5
5
  Author-email: Daniel Hails <slidesync@hails.info>
6
6
  License: MIT
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
22
22
  Slides** — as native, editable objects (title/body/bullets/tables/positioned
23
23
  images, brand-styled text boxes), not pasted screenshots.
24
24
 
25
- Version: 0.2.0
25
+ Version: 0.3.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -63,6 +63,8 @@ Support path.)
63
63
  | `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
64
64
  | `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
65
65
  | `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
66
+ | `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
67
+ | `slidesync sync <file.slidev.md> [--deck ID]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
66
68
 
67
69
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
68
70
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -87,6 +89,25 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
89
  path, template vars — and, for template slides, the authored body markdown
88
90
  (base64) — so `pull` recovers the source verbatim.
89
91
 
92
+ ## Sync & drift (detection, not resolution)
93
+
94
+ The marker's last-pushed source is a true per-slide **merge base**, so `sync`
95
+ classifies each slide three-way without timestamps (the Slides API has no
96
+ per-slide edit times — only file-level `modifiedTime`; the marker's `at` stamp
97
+ records when *we* last pushed each slide):
98
+
99
+ | status | meaning | action |
100
+ |--------|---------|--------|
101
+ | `clean` / `converged` | nothing changed, or both sides made the same change | — |
102
+ | `local-edit` | markdown changed, deck untouched | `push` (normal) |
103
+ | `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
104
+ | `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
105
+
106
+ Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
107
+ blocks on their slide (replies as extra `@Author:` lines) — paste them into the
108
+ source, where they round-trip from then on. Threads anchor to slide objectIds,
109
+ so a re-render orphans them: `sync` reports those too. Capture before you push.
110
+
90
111
  ## Markdown dialect
91
112
 
92
113
  Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
@@ -4,7 +4,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
4
4
  Slides** — as native, editable objects (title/body/bullets/tables/positioned
5
5
  images, brand-styled text boxes), not pasted screenshots.
6
6
 
7
- Version: 0.2.0
7
+ Version: 0.3.0
8
8
 
9
9
  ```bash
10
10
  uvx slidesync --help # run without installing
@@ -45,6 +45,8 @@ Support path.)
45
45
  | `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
46
46
  | `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
47
47
  | `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
48
+ | `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
49
+ | `slidesync sync <file.slidev.md> [--deck ID]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
48
50
 
49
51
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
50
52
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -69,6 +71,25 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
69
71
  path, template vars — and, for template slides, the authored body markdown
70
72
  (base64) — so `pull` recovers the source verbatim.
71
73
 
74
+ ## Sync & drift (detection, not resolution)
75
+
76
+ The marker's last-pushed source is a true per-slide **merge base**, so `sync`
77
+ classifies each slide three-way without timestamps (the Slides API has no
78
+ per-slide edit times — only file-level `modifiedTime`; the marker's `at` stamp
79
+ records when *we* last pushed each slide):
80
+
81
+ | status | meaning | action |
82
+ |--------|---------|--------|
83
+ | `clean` / `converged` | nothing changed, or both sides made the same change | — |
84
+ | `local-edit` | markdown changed, deck untouched | `push` (normal) |
85
+ | `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
86
+ | `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
87
+
88
+ Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
89
+ blocks on their slide (replies as extra `@Author:` lines) — paste them into the
90
+ source, where they round-trip from then on. Threads anchor to slide objectIds,
91
+ so a re-render orphans them: `sync` reports those too. Capture before you push.
92
+
72
93
  ## Markdown dialect
73
94
 
74
95
  Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slidesync"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  authors = [
5
5
  { name = "Daniel Hails", email = "slidesync@hails.info" },
6
6
  ]
@@ -32,7 +32,7 @@ include = ["slidesync*"]
32
32
  dev = ["pytest>=8"]
33
33
 
34
34
  [tool.bumpver]
35
- current_version = "0.2.0"
35
+ current_version = "0.3.0"
36
36
  version_pattern = "MAJOR.MINOR.PATCH"
37
37
  commit_message = "bump version {old_version} -> {new_version}"
38
38
  commit = true
@@ -30,7 +30,7 @@ from slidesync._sync import (
30
30
  write_slidev,
31
31
  )
32
32
 
33
- __version__ = "0.2.0"
33
+ __version__ = "0.3.0"
34
34
 
35
35
  __all__ = [
36
36
  "Para",
@@ -36,6 +36,8 @@ from __future__ import annotations
36
36
 
37
37
  import argparse
38
38
  import base64
39
+ import datetime
40
+ import difflib
39
41
  import hashlib
40
42
  import html
41
43
  import json
@@ -580,6 +582,10 @@ def _marker(slide: Slide) -> str:
580
582
  data["src"] = base64.b64encode(slide.src.encode()).decode()
581
583
  elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
582
584
  data["tpl"] = slide.layout_name
585
+ # Last-push stamp: `sync` reports it alongside drift. The Slides/Drive APIs
586
+ # have no per-slide edit times (file-level modifiedTime only), so this is
587
+ # the only per-slide timestamp that exists.
588
+ data["at"] = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
583
589
  return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
584
590
 
585
591
 
@@ -1890,6 +1896,192 @@ def cmd_layouts(args):
1890
1896
  logger.info(f"{name:<24} {', '.join(phs) or '(no placeholders)'}")
1891
1897
 
1892
1898
 
1899
+ # ---------------------------------------------------------------------------
1900
+ # Comments + sync (drift detection)
1901
+ # ---------------------------------------------------------------------------
1902
+
1903
+
1904
+ def shape_comments(raw: list[dict]) -> list[dict]:
1905
+ """Drive comment threads -> [{id, page, author, content, resolved, replies}].
1906
+
1907
+ `page` is the anchored slide objectId (None for file-level comments) —
1908
+ note an objectId outlives its slide, so the anchor may point at a deleted
1909
+ page after a re-render. Resolve-action replies with no text are dropped.
1910
+ """
1911
+ out = []
1912
+ for c in raw:
1913
+ try:
1914
+ anchor = json.loads(c.get("anchor") or "{}")
1915
+ except json.JSONDecodeError:
1916
+ anchor = {}
1917
+ pages = anchor.get("pages") or []
1918
+ out.append({
1919
+ "id": c.get("id", ""),
1920
+ "page": pages[0] if pages else None,
1921
+ "author": (c.get("author") or {}).get("displayName", ""),
1922
+ "content": (c.get("content") or "").strip(),
1923
+ "resolved": bool(c.get("resolved")),
1924
+ "modified": c.get("modifiedTime", ""),
1925
+ "replies": [
1926
+ {"author": (r.get("author") or {}).get("displayName", ""),
1927
+ "content": (r.get("content") or "").strip()}
1928
+ for r in c.get("replies", []) if (r.get("content") or "").strip()
1929
+ ],
1930
+ })
1931
+ return out
1932
+
1933
+
1934
+ _COMMENT_FIELDS = ("nextPageToken,comments(id,content,author(displayName),"
1935
+ "anchor,resolved,modifiedTime,replies(content,author(displayName)))")
1936
+
1937
+
1938
+ def list_comments(drive, deck: str) -> list[dict]:
1939
+ raw, token = [], None
1940
+ while True:
1941
+ resp = drive.comments().list(fileId=deck, pageSize=100, pageToken=token,
1942
+ fields=_COMMENT_FIELDS).execute()
1943
+ raw += resp.get("comments", [])
1944
+ token = resp.get("nextPageToken")
1945
+ if not token:
1946
+ return raw
1947
+
1948
+
1949
+ def cmd_comments(args):
1950
+ _, drive = get_services(args.account)
1951
+ print(json.dumps(shape_comments(list_comments(drive, args.deck)), indent=2))
1952
+
1953
+
1954
+ def text_lines_md(src: str) -> list[str]:
1955
+ """Markdown slide body -> normalised visible text lines (comments excluded)."""
1956
+ fences = [m.group("text") for m in VERBATIM_RE.finditer(src)]
1957
+ headings, paras, _img, _alt, table, _ = parse_body(VERBATIM_RE.sub("", src))
1958
+ lines = [headings[k] for k in sorted(headings)]
1959
+ lines += [p.text for p in paras]
1960
+ if table:
1961
+ lines += [" | ".join(row) for row in table]
1962
+ for f in fences:
1963
+ lines += f.splitlines()
1964
+ return _norm_lines(lines)
1965
+
1966
+
1967
+ def text_lines_native(s: dict) -> list[str]:
1968
+ """Native slide JSON -> normalised visible text lines (notes excluded)."""
1969
+ boxes = []
1970
+ for el in s.get("pageElements", []):
1971
+ if el.get("shape", {}).get("text"):
1972
+ paras = _paras_from_shape(el["shape"])
1973
+ if paras:
1974
+ boxes.append((_el_y(el), _el_x(el), paras))
1975
+ elif el.get("table"):
1976
+ rows = _table_from_native(el["table"])
1977
+ boxes.append((_el_y(el), _el_x(el),
1978
+ [Para(" | ".join(r)) for r in rows]))
1979
+ boxes.sort(key=lambda t: (t[0], t[1]))
1980
+ return _norm_lines([p.text for _, _, paras in boxes for p in paras])
1981
+
1982
+
1983
+ def _norm_lines(lines: list[str]) -> list[str]:
1984
+ # Sorted: box reading-order vs markdown order differ legitimately (e.g. a
1985
+ # kicker renders above the headline but is written below it).
1986
+ return sorted(" ".join(line.split()) for line in lines if line.strip())
1987
+
1988
+
1989
+ def classify_drift(base: list[str] | None, local: list[str],
1990
+ live: list[str]) -> str:
1991
+ """Three-way status for one slide; base is the last-pushed source (marker)."""
1992
+ if base is None:
1993
+ return "clean" if local == live else "drift-no-base"
1994
+ if local == base and live == base:
1995
+ return "clean"
1996
+ if local != base and live == base:
1997
+ return "local-edit"
1998
+ if local == base and live != base:
1999
+ return "live-drift"
2000
+ return "converged" if local == live else "conflict"
2001
+
2002
+
2003
+ def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
2004
+ return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
2005
+
2006
+
2007
+ def cmd_sync(args):
2008
+ """Report per-slide drift between a markdown deck and its live Slides copy.
2009
+
2010
+ Detection, not resolution: each slide is classified against the marker's
2011
+ last-pushed source (the merge base). Additive changes (comments, notes-pane
2012
+ edits) are printed as ready-to-paste `<!-- @Author: ... -->` blocks; text
2013
+ conflicts are printed as unified diffs for a human/LLM to fold into the
2014
+ markdown, after which a normal `push` (or `push --force` to clobber
2015
+ live-only drift) makes the file authoritative again. Exits 1 if anything
2016
+ drifted.
2017
+ """
2018
+ slides_api, drive = get_services(args.account)
2019
+ source = load_slides(args.source)
2020
+ deck = args.deck or deck_from_source(args.source)
2021
+ if not deck:
2022
+ sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
2023
+ pres = slides_api.presentations().get(presentationId=deck).execute()
2024
+ live = {s["objectId"].split("_")[1]: s for s in pres.get("slides", [])
2025
+ if MANAGED_RE.match(s["objectId"])}
2026
+ by_page = {}
2027
+ for c in shape_comments(list_comments(drive, deck)):
2028
+ if not c["resolved"] and c["page"]:
2029
+ by_page.setdefault(c["page"], []).append(c)
2030
+ drifted = 0
2031
+ for sl in source:
2032
+ s = live.pop(sl.key_hash, None)
2033
+ if s is None:
2034
+ logger.warning(f"[missing ] {sl.key} — not in deck; push will create it")
2035
+ drifted += 1
2036
+ continue
2037
+ for c in by_page.pop(s["objectId"], []):
2038
+ drifted += 1
2039
+ lines = [f"@{c['author']}: {c['content']}"]
2040
+ lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
2041
+ logger.info(f"[comment ] {sl.key} ({c['modified']}):\n"
2042
+ + "<!-- " + "\n".join(lines) + " -->")
2043
+ if sl.custom is not None:
2044
+ continue # pull-authoritative; drawings are captured, not diffed
2045
+ notes_raw = _read_notes(s)
2046
+ marker = _read_marker(notes_raw)
2047
+ base_src = (base64.b64decode(marker["src"]).decode()
2048
+ if "src" in marker else None)
2049
+ base = text_lines_md(base_src) if base_src is not None else None
2050
+ local = text_lines_md(sl.src or "")
2051
+ live_lines = text_lines_native(s)
2052
+ status = classify_drift(base, local, live_lines)
2053
+ pushed_at = marker.get("at", "?")
2054
+ if status in ("clean", "converged"):
2055
+ continue
2056
+ drifted += 1
2057
+ if status == "local-edit":
2058
+ logger.info(f"[local-edit] {sl.key} — push will update it")
2059
+ elif status in ("live-drift", "drift-no-base"):
2060
+ logger.warning(f"[live-drift] {sl.key} (pushed {pushed_at}) — edited in "
2061
+ f"Slides; fold into the markdown or `push --force` to clobber:\n"
2062
+ + _diff(base if base is not None else local, live_lines,
2063
+ "last-pushed" if base is not None else "local", "live"))
2064
+ else: # conflict
2065
+ logger.error(f"[conflict ] {sl.key} (pushed {pushed_at}) — both sides "
2066
+ f"changed since last push:\n"
2067
+ + _diff(base, local, "last-pushed", "local") + "\n"
2068
+ + _diff(base, live_lines, "last-pushed", "live"))
2069
+ live_notes = " ".join(MARKER_RE.sub("", notes_raw).split())
2070
+ base_notes = " ".join(_extract_notes(base_src).split()) if base_src else ""
2071
+ if base_src is not None and live_notes != base_notes:
2072
+ logger.info(f"[notes ] {sl.key} — notes pane edited live:\n{live_notes}")
2073
+ for kh, s in live.items():
2074
+ logger.warning(f"[unmanaged ] {s['objectId']} in deck has no local slide "
2075
+ "(--prune on push would delete it)")
2076
+ for page, cs in by_page.items():
2077
+ for c in cs:
2078
+ logger.warning(f"[orphaned ] comment by {c['author']} anchored to deleted "
2079
+ f"slide {page}: {c['content']!r}")
2080
+ logger.log("WARNING" if drifted else "SUCCESS",
2081
+ f"{drifted} drifted item(s)" if drifted else "deck matches source")
2082
+ sys.exit(1 if drifted else 0)
2083
+
2084
+
1893
2085
  def _loop_hop(slides_api, drive, title, slides):
1894
2086
  """One md->slides hop: build a deck, push, pull back."""
1895
2087
  deck = new_deck(slides_api, title)
@@ -1966,6 +2158,17 @@ def main():
1966
2158
  p.add_argument("deck")
1967
2159
  p.set_defaults(func=cmd_layouts)
1968
2160
 
2161
+ p = sub.add_parser("comments",
2162
+ help="list comment threads as JSON (page anchor, author, replies)")
2163
+ p.add_argument("deck")
2164
+ p.set_defaults(func=cmd_comments)
2165
+
2166
+ p = sub.add_parser("sync",
2167
+ help="report drift vs the live deck (comments, live edits, conflicts)")
2168
+ p.add_argument("source", type=Path)
2169
+ p.add_argument("--deck")
2170
+ p.set_defaults(func=cmd_sync)
2171
+
1969
2172
  p = sub.add_parser("make-templates",
1970
2173
  help="add branded tagged template slides to a deck")
1971
2174
  p.add_argument("deck")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
5
5
  Author-email: Daniel Hails <slidesync@hails.info>
6
6
  License: MIT
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
22
22
  Slides** — as native, editable objects (title/body/bullets/tables/positioned
23
23
  images, brand-styled text boxes), not pasted screenshots.
24
24
 
25
- Version: 0.2.0
25
+ Version: 0.3.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -63,6 +63,8 @@ Support path.)
63
63
  | `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
64
64
  | `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
65
65
  | `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
66
+ | `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
67
+ | `slidesync sync <file.slidev.md> [--deck ID]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
66
68
 
67
69
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
68
70
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -87,6 +89,25 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
89
  path, template vars — and, for template slides, the authored body markdown
88
90
  (base64) — so `pull` recovers the source verbatim.
89
91
 
92
+ ## Sync & drift (detection, not resolution)
93
+
94
+ The marker's last-pushed source is a true per-slide **merge base**, so `sync`
95
+ classifies each slide three-way without timestamps (the Slides API has no
96
+ per-slide edit times — only file-level `modifiedTime`; the marker's `at` stamp
97
+ records when *we* last pushed each slide):
98
+
99
+ | status | meaning | action |
100
+ |--------|---------|--------|
101
+ | `clean` / `converged` | nothing changed, or both sides made the same change | — |
102
+ | `local-edit` | markdown changed, deck untouched | `push` (normal) |
103
+ | `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
104
+ | `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
105
+
106
+ Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
107
+ blocks on their slide (replies as extra `@Author:` lines) — paste them into the
108
+ source, where they round-trip from then on. Threads anchor to slide objectIds,
109
+ so a re-render orphans them: `sync` reports those too. Capture before you push.
110
+
90
111
  ## Markdown dialect
91
112
 
92
113
  Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
@@ -12,4 +12,5 @@ slidesync.egg-info/requires.txt
12
12
  slidesync.egg-info/top_level.txt
13
13
  tests/test_comment_preservation.py
14
14
  tests/test_markdown.py
15
- tests/test_pull.py
15
+ tests/test_pull.py
16
+ tests/test_sync_drift.py
@@ -0,0 +1,67 @@
1
+ """Offline tests for drift detection: comment shaping, text-line extraction
2
+ parity (markdown vs native), and the three-way classification."""
3
+
4
+ from slidesync._sync import (
5
+ classify_drift,
6
+ shape_comments,
7
+ text_lines_md,
8
+ text_lines_native,
9
+ )
10
+
11
+ RAW_COMMENTS = [
12
+ {"id": "c1",
13
+ "anchor": '{"type":"page","uid":1,"pages":["s2g_aaaaaaaaaa_bbbbbbbbbb"]}',
14
+ "author": {"displayName": "Daniel Hails"},
15
+ "content": "This is obviously dodgy. To debug",
16
+ "modifiedTime": "2026-06-12T15:00:00Z",
17
+ "replies": [
18
+ {"author": {"displayName": "Ted"}, "content": "agree"},
19
+ {"author": {"displayName": "Daniel Hails"}, "content": ""}, # resolve action
20
+ ]},
21
+ {"id": "c2", "anchor": "not json", "author": {}, "content": "file-level",
22
+ "resolved": True},
23
+ ]
24
+
25
+
26
+ def test_shape_comments_extracts_page_author_replies():
27
+ a, b = shape_comments(RAW_COMMENTS)
28
+ assert a["page"] == "s2g_aaaaaaaaaa_bbbbbbbbbb"
29
+ assert a["author"] == "Daniel Hails" and not a["resolved"]
30
+ assert a["replies"] == [{"author": "Ted", "content": "agree"}]
31
+ assert b["page"] is None and b["resolved"] is True
32
+
33
+
34
+ def _native(texts):
35
+ """A native slide with one text box per entry, stacked top to bottom."""
36
+ els = []
37
+ for i, text in enumerate(texts):
38
+ els.append({"transform": {"translateY": i * 914400, "translateX": 0},
39
+ "shape": {"text": {"textElements": [
40
+ {"paragraphMarker": {}},
41
+ {"textRun": {"content": text, "style": {}}}]}}})
42
+ return {"objectId": "s", "pageElements": els}
43
+
44
+
45
+ def test_md_and_native_text_lines_agree_for_a_styled_slide():
46
+ md = "# 2026/06/15\n## RELIABLE MONITORS\n\nWeekly Update\n<!-- a note -->"
47
+ # live render stacks kicker above headline — order differs, content matches
48
+ native = _native(["RELIABLE MONITORS", "2026/06/15", "Weekly Update"])
49
+ assert text_lines_md(md) == text_lines_native(native)
50
+
51
+
52
+ def test_md_text_lines_include_verbatim_fences_and_skip_comments():
53
+ md = "## PROMPT\n```text\nYou are a monitor.\n```\n<!-- hidden -->"
54
+ lines = text_lines_md(md)
55
+ assert "You are a monitor." in lines
56
+ assert not any("hidden" in line for line in lines)
57
+
58
+
59
+ def test_classify_drift_three_way():
60
+ base, local, live = ["a"], ["a"], ["a"]
61
+ assert classify_drift(base, local, live) == "clean"
62
+ assert classify_drift(base, ["b"], live) == "local-edit"
63
+ assert classify_drift(base, local, ["b"]) == "live-drift"
64
+ assert classify_drift(base, ["b"], ["c"]) == "conflict"
65
+ assert classify_drift(base, ["b"], ["b"]) == "converged"
66
+ assert classify_drift(None, ["a"], ["a"]) == "clean"
67
+ assert classify_drift(None, ["a"], ["b"]) == "drift-no-base"
File without changes
File without changes
File without changes
File without changes