Files
bvandeusenandClaude Opus 5 fa89da1fab
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
notes: color leaves the model, the wire and all three surfaces
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of
this field: a card is one neutral surface per theme, and the only coloured
thing on a board is a tag. What was left was a column written by a picker and
read by nothing.

Rule 22 — the old path comes out completely. No flag, no fallback, no
"override if set".

Server: the column, the `?color=` facet, the create/update/serialise paths,
the sync assignment, the front-matter line, and Keep's colour map. Alembic
0029 drops it and sweeps `"color"` out of stored saved-filter params — a view
that silently filtered on a field the app no longer has would return nothing
and never say why. That sweep is Python, not `params::jsonb - 'color'`,
because Postgres has no try-cast and one malformed blob would abort a
migration that is running over somebody's saved views.

`NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on
the model that lost one is an invitation to put the column back; labels still
name a colour, so the vocabulary belongs where the normalizer already is.

Core: the field, the facet, the `NoteCreateInput`, and every read and write in
store/push/pull. Local schema v9 drops the column and does the same
saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key
rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and
`NoteDraft.color` with it.

Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule,
the FilterBar colour row, the facet in the query round-trip, and the colour
half of the editor's baseline-and-save. Android: the `ColorSheet`, the
`Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`.

## The protocol: v4, and the floor deliberately stays at 3

Checked against `compat.rs` and the push handler rather than trusting the
`#[serde(default)]` annotation, because the v2 precedent points the other way:
v2 dropped `kind` and `title` and DID raise both floors, on the rule that
dropping a field a client sends and expects back is breaking.

`color` fails the second half of that test. A v3 client reading a v4 note gets
`"default"` from its own serde default and draws the colour it derives
locally — the board it drew yesterday. A v3 client pushing `color` has the key
ignored, since `_assign_note_fields` reads its payload key by key and never
validates the shape. Neither direction errors and neither shows anything
wrong. `title` was the note's NAME; this is a field that no longer renders.

So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both
floors stay at 3. `docs/sync.md` carries the reasoning and the per-version
history, and its push example is brought back in line — it still listed
`title`, `kind` and `items`, all gone before this.

Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:`
imports fine, the key simply read past. Old exports must still import.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:07:03 -04:00

675 lines
27 KiB
Python

from datetime import datetime, timezone
import pytest
from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.colors import NOTE_COLORS
from thoughtsync.models.note import Note
from thoughtsync.notes.checklist import (
append_item,
parse_items,
remove_item,
set_item_checked,
set_item_text,
strip_marker,
)
from thoughtsync.unfurl_queue import detect_urls
from thoughtsync.notes import (
_attachment_ext,
_header_filename,
_keep_spec,
_native_spec,
_note_markdown,
_safe_filename,
_slugify,
_usec_to_dt,
derive_display_title,
is_empty_note,
next_occurrence,
normalize_color,
normalize_recurrence,
parse_list_items,
parse_tags,
)
from thoughtsync.notes.tags import split_body_tags
@pytest.fixture
def app():
return create_app()
def test_all_note_routes_registered(app):
# Guards the notes package split: every route handler must still be attached to the
# blueprint. A route whose module isn't imported by notes/__init__ would silently
# 404 at runtime, and most routes have no auth-guard test to otherwise catch it.
registered = {r.endpoint for r in app.url_map.iter_rules()}
expected = {
f"notes.{name}"
for name in (
"list_notes", "list_reminders", "complete_reminder",
"snooze_reminder", "export_notes", "import_notes", "list_titles",
"reorder_notes", "create_note",
"get_note", "update_note", "list_revisions", "restore_revision",
"set_note_labels", "add_item", "update_item", "delete_item",
"upload_attachment", "get_attachment",
"delete_attachment", "unfurl_link", "delete_preview", "trash_note",
"restore_note", "delete_note",
)
}
assert expected <= registered, f"unregistered note routes: {expected - registered}"
def test_is_empty_note():
assert is_empty_note(None, None)
assert is_empty_note(" ", [])
assert not is_empty_note("body")
# A note that is only a checklist is not empty — it just has nothing in its body.
assert not is_empty_note("", ["milk"])
def test_normalize_color():
assert normalize_color("blue") == "blue"
assert normalize_color("chartreuse") == "default"
assert normalize_color(None) == "default"
assert normalize_color(123) == "default"
def test_palette_has_core_colors():
# A LABEL's vocabulary since M315 — a note has no colour to be one of these.
for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"):
assert c in NOTE_COLORS
def test_serialize_shape():
n = Note(body="b", pinned=True, archived=False)
s = n.serialize()
assert "title" not in s # there is no title field any more (M13 step 3)
assert s["body"] == "b"
assert s["pinned"] is True
assert s["archived"] is False
assert s["trashed"] is False
async def test_notes_list_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes")
assert resp.status_code == 401
async def test_notes_create_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes", json={"body": "hi"})
assert resp.status_code == 401
async def test_search_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/search?q=hello")
assert resp.status_code == 401
async def test_add_item_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"})
assert resp.status_code == 401
async def test_upload_attachment_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments")
assert resp.status_code == 401
async def test_reorder_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/reorder", json={"ids": []})
assert resp.status_code == 401
# [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
# the name index and the `[[` autocomplete. So are the two helpers that used to keep
# links alive across a rename, and the id-binding that briefly replaced them. Nothing
# here asserts their absence — `test_all_note_routes_registered` below is what would
# notice a route coming back, and the removal is one commit rather than a fossil.
def test_derive_display_title_is_the_first_body_line():
assert derive_display_title("first line\nsecond line") == "first line"
assert derive_display_title(" spaced first \nnext") == "spaced first"
# leading blank/whitespace lines are skipped to the first line with content
assert derive_display_title("\n \nreal line\nmore") == "real line"
def test_derive_display_title_names_a_list_only_note():
# The same property the old `first_item` fallback protected — a note that is only
# a checklist still has a name — reached a different way. Items ARE body lines now
# (M304), so the first one is simply the first line, with its marker stripped:
# calling the note "- [ ] milk" would show someone the storage instead of the note.
assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert derive_display_title(" * [x] eggs ") == "eggs"
# Prose still wins when it comes first, because it IS the first line.
assert derive_display_title("shopping\n\n- [ ] milk") == "shopping"
# An EMPTY item does not name the note "" — a half-typed list still has a name.
assert derive_display_title("- [ ]\n- [ ] eggs") == "eggs"
def test_derive_display_title_empty():
assert derive_display_title(None) == ""
assert derive_display_title("") == ""
assert derive_display_title(" \n ") == ""
# A list of nothing but empty items is still a note with no name.
assert derive_display_title("- [ ]\n- [ ]") == ""
def test_derive_display_title_caps_length():
long = "x" * 300
assert derive_display_title(long) == "x" * 200
# A first line that happens to be an item is capped on the same rule.
assert derive_display_title(f"- [ ] {long}") == "x" * 200
def test_split_body_tags_lifts_only_a_line_that_is_nothing_else():
"""The rule, in the cases it exists to get right.
A tag on a line of its own is filing and the line can go. A tag sharing a line
with words is part of what was written, and taking it out would leave "remember to
call" — so the line is left exactly as typed. The trailing-tag case (`buy milk
#grocery`) is deliberately on the conservative side of the line: it reads like
filing, but nothing in the text distinguishes it from `remember to call #mom`, and
guessing wrong mangles a sentence to save a duplicate chip.
"""
assert split_body_tags("#todo\nreorganize the homepage") == (["todo"], [], "reorganize the homepage")
assert split_body_tags("needs a tauri app\n#todo") == (["todo"], [], "needs a tauri app")
assert split_body_tags("#todo #work\nreal text") == (["todo", "work"], [], "real text")
unchanged = "remember to call #mom tomorrow"
assert split_body_tags(unchanged) == ([], ["mom"], unchanged)
trailing = "buy milk #grocery"
assert split_body_tags(trailing) == ([], ["grocery"], trailing)
def test_split_body_tags_leaves_the_note_readable():
# Removing a line must not leave a hole where it was.
assert split_body_tags("foo\n\n#todo\n\nbar") == (["todo"], [], "foo\n\nbar")
# A note that is NOTHING but tags would be blanked. A duplicated chip beats an
# empty card, so it keeps its text and its tags stay derived.
assert split_body_tags("#todo") == ([], ["todo"], "#todo")
assert split_body_tags("#todo #work") == ([], ["todo", "work"], "#todo #work")
# A tag in a code fence is CODE — a shell comment in somebody's snippet. It still
# becomes a label, because it always has, but the line is never touched.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert split_body_tags(fenced) == ([], ["deploy"], fenced)
# Not a tag at all (no letter), so not a tag-only line either.
assert split_body_tags("#2024\nreal") == ([], [], "#2024\nreal")
assert split_body_tags("") == ([], [], "")
assert split_body_tags(None) == ([], [], "")
def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it():
"""Appearing on its own line does NOT lift a tag that is also written in a
sentence — the sentence still backs it, so deleting that sentence should still
detach the label. Standalone and inline are not both true of one tag."""
standalone, inline, body = split_body_tags("#todo\nremember the #todo list")
assert standalone == []
assert inline == ["todo"]
assert body == "remember the #todo list"
def test_migration_0028_lifts_exactly_what_the_app_lifts_today():
"""The 0028 data migration rewrites note bodies, and that is not undoable.
It carries its OWN frozen copy of the rule rather than importing
`split_body_tags`, on 0027's principle that a migration must keep producing what
it produced the day it ran. This does not assert the two agree — they are allowed
to diverge later, which is the entire point of freezing one. It pins the frozen
copy against fixed expectations, so nobody can "tidy" it into eating prose.
"""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0028_lift_standalone_tags.py"
spec = importlib.util.spec_from_file_location("migration_0028", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Lifted: the tag was the whole line.
assert mod._split("#todo\nreorganize the homepage") == (["todo"], "reorganize the homepage")
assert mod._split("needs a tauri app\n#todo") == (["todo"], "needs a tauri app")
assert mod._split("#todo #work\nreal text") == (["todo", "work"], "real text")
assert mod._split("foo\n\n#todo\n\nbar") == (["todo"], "foo\n\nbar")
# Untouched: prose. Getting any of these wrong destroys somebody's words.
for prose in ("remember to call #mom tomorrow", "buy milk #grocery", "#2024\nreal"):
assert mod._split(prose) == ([], prose), prose
# Untouched: a tag inside a fence is a shell comment in somebody's snippet.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert mod._split(fenced) == ([], fenced)
# Untouched: a note that is nothing but tags would be blanked.
assert mod._split("#todo") == ([], "#todo")
# Not flipped: the tag is still written in prose, so its text still backs it and
# it must stay derived — flipping it would be claiming otherwise.
assert mod._split("#todo\nremember the #todo list") == ([], "remember the #todo list")
# The name has to move with the body, or a note is titled after a deleted line.
assert mod._display_title("reorganize the homepage") == "reorganize the homepage"
assert mod._display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert mod._display_title("") == ""
def test_parse_tags():
assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"]
# case-insensitive dedup, first spelling wins
assert parse_tags("#Work then #work") == ["Work"]
# url fragments, mid-word #, purely-numeric, and a bare # are not tags
assert parse_tags("frag http://x/#nope mid#word #2024 #") == []
assert parse_tags(None) == []
assert parse_tags("#a #b #a") == ["a", "b"]
def test_parse_list_items():
assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"]
assert parse_list_items("not a list") == []
assert parse_list_items(None) == []
assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"]
def test_parse_dt():
# A full ISO instant round-trips (used to validate the Timeline date range).
d = parse_dt("2026-07-19T12:30:00+00:00")
assert (d.year, d.month, d.day, d.hour, d.minute) == (2026, 7, 19, 12, 30)
assert d.tzinfo is not None
# a trailing Z is accepted as UTC
assert parse_dt("2026-07-19T00:00:00Z").tzinfo is not None
# a plain calendar date parses to midnight
assert parse_dt("2026-07-19").hour == 0
# garbage / non-strings return None (the endpoint turns this into a 400)
assert parse_dt("not-a-date") is None
assert parse_dt("") is None
assert parse_dt(None) is None
async def test_titles_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/titles")
assert resp.status_code == 401
async def test_reminders_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/reminders")
assert resp.status_code == 401
def test_slugify():
assert _slugify("My Great Note!") == "my-great-note"
assert _slugify(" spaced / weird __name ") == "spaced-weird-name"
assert _slugify("") == "note" # empty falls back
assert _slugify("!!!") == "note" # all punctuation strips to empty → fallback
assert len(_slugify("x" * 100)) == 60
async def test_export_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/export")
assert resp.status_code == 401
async def test_list_revisions_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions")
assert resp.status_code == 401
async def test_restore_revision_requires_auth(app):
client = app.test_client()
resp = await client.post(
"/api/notes/00000000-0000-0000-0000-000000000000/revisions/00000000-0000-0000-0000-000000000001/restore"
)
assert resp.status_code == 401
async def test_import_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/import")
assert resp.status_code == 401
def test_safe_filename():
assert _safe_filename("report.pdf") == "report.pdf"
assert _safe_filename("/etc/passwd") == "passwd" # path components stripped
assert _safe_filename("a\\b\\c.doc") == "c.doc" # windows separators too
assert _safe_filename("") == "file" # fallback
assert _safe_filename(None) == "file"
def test_attachment_ext():
assert _attachment_ext("report.pdf", "application/pdf") == ".pdf"
assert _attachment_ext("memo.m4a", "audio/mp4") == ".m4a"
# no extension in the name → fall back to a known image mime, else empty
assert _attachment_ext("noext", "image/png") == ".png"
assert _attachment_ext("noext", "application/octet-stream") == ""
def test_header_filename():
# Quotes/newlines are stripped so the Content-Disposition header can't be broken.
assert _header_filename('a"b\r\n.pdf') == "ab.pdf"
assert _header_filename("") == "file"
def test_coerce_bool():
assert coerce_bool("true") and coerce_bool("1") and coerce_bool("yes") and coerce_bool("on")
assert coerce_bool(True)
assert not coerce_bool("false")
assert not coerce_bool(None)
assert not coerce_bool("")
assert not coerce_bool(False)
def test_normalize_recurrence():
for v in ("daily", "weekly", "monthly", "yearly"):
assert normalize_recurrence(v) == v
assert normalize_recurrence("none") is None
assert normalize_recurrence("") is None
assert normalize_recurrence(None) is None
assert normalize_recurrence("hourly") is None
def test_next_occurrence_daily_weekly():
base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc)
after = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc) # same day, later
assert next_occurrence(base, "daily", after) == datetime(2026, 7, 2, 9, 0, tzinfo=timezone.utc)
assert next_occurrence(base, "weekly", after) == datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc)
def test_next_occurrence_skips_missed():
base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc)
after = datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc) # 9+ days later
# Rolls forward past every missed day to the first fire strictly after `after`.
assert next_occurrence(base, "daily", after) == datetime(2026, 7, 11, 9, 0, tzinfo=timezone.utc)
def test_next_occurrence_monthly_clamps_month_end():
base = datetime(2026, 1, 31, 8, 0, tzinfo=timezone.utc)
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
# Jan 31 + 1 month → Feb 28 (clamped to the shorter month).
assert next_occurrence(base, "monthly", after) == datetime(2026, 2, 28, 8, 0, tzinfo=timezone.utc)
def test_next_occurrence_yearly_and_none():
base = datetime(2026, 3, 15, 7, 0, tzinfo=timezone.utc)
after = datetime(2026, 3, 16, tzinfo=timezone.utc)
assert next_occurrence(base, "yearly", after) == datetime(2027, 3, 15, 7, 0, tzinfo=timezone.utc)
assert next_occurrence(base, "none", after) is None
async def test_complete_reminder_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/reminder/complete")
assert resp.status_code == 401
async def test_snooze_reminder_requires_auth(app):
client = app.test_client()
resp = await client.post(
"/api/notes/00000000-0000-0000-0000-000000000000/reminder/snooze", json={"minutes": 10}
)
assert resp.status_code == 401
def test_usec_to_dt():
# Google Keep timestamps are microseconds since the epoch (UTC).
d = _usec_to_dt(1600000000000000)
assert d is not None and d.year == 2020 and d.tzinfo is not None
# garbage / missing → None (the note still imports, just without the timestamp)
assert _usec_to_dt("nope") is None
assert _usec_to_dt(None) is None
def test_keep_spec_list_note_keeps_its_text_too():
# Keep's own notes carry one or the other, but its textContent used to be
# DISCARDED whenever a note also had listContent, because a note could only be
# one kind. A note holds both now, so nothing is dropped on the way in.
kn = {
"title": "Groceries",
"textContent": "for the weekend",
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
"labels": [{"name": "shopping"}],
# Keep's own colour, which the importer now reads past: there is nothing on a
# note for it to land on, and a spec carrying a key nobody applies is a lie.
"color": "TEAL",
"isPinned": True,
"isArchived": False,
"isTrashed": False,
"createdTimestampUsec": 1600000000000000,
"userEditedTimestampUsec": 1600000100000000,
}
spec = _keep_spec(kn, "Takeout/Keep")
assert spec["body"] == "for the weekend"
assert "color" not in spec
assert spec["pinned"] is True
assert spec["archived"] is False
assert spec["trashed"] is False
assert spec["items"] == [{"text": "Milk", "checked": False}, {"text": "Eggs", "checked": True}]
assert spec["labels"] == ["shopping"]
assert spec["created_at"].year == 2020
def test_keep_spec_text_note_folds_annotation_urls_and_drops_color():
kn = {
"textContent": "Read this later",
"annotations": [{"url": "https://example.com"}],
"color": "BROWN",
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
}
spec = _keep_spec(kn, "Takeout/Keep")
assert "https://example.com" in spec["body"]
# BROWN used to map to the nearest hue we had. There is no hue to map TO now, so a
# Keep import brings across everything except the one thing this app stopped having.
assert "color" not in spec
# attachment path is resolved relative to the note JSON's folder
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
def test_native_spec_roundtrip_fields():
n = {
"title": "T",
"body": "b",
# An export taken before M315 still carries this. Reading past it rather than
# rejecting the file is the whole point — old exports must still import.
"color": "blue",
"pinned": True,
"archived": False,
"created_at": "2026-07-19T00:00:00+00:00",
"labels": ["x"],
"items": [],
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
}
spec = _native_spec(n)
# The spec still CARRIES a title — an export taken before M13 has one, and
# _create_imported_note folds it into the body rather than dropping it.
assert spec["title"] == "T"
assert spec["body"] == "b"
assert "color" not in spec
assert spec["pinned"] is True
assert spec["trashed"] is False # exports only carry live notes
assert spec["created_at"].year == 2026
assert spec["labels"] == ["x"]
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]
def test_detect_urls_finds_each_link_once_in_order():
body = "see https://example.com/a and https://example.com/b\nand https://example.com/a again"
assert detect_urls(body) == ["https://example.com/a", "https://example.com/b"]
def test_detect_urls_trims_sentence_punctuation():
# A URL can end in most punctuation; a SENTENCE containing one usually doesn't.
assert detect_urls("read https://example.com/page.") == ["https://example.com/page"]
assert detect_urls("(see https://example.com/x)") == ["https://example.com/x"]
# …but a path that legitimately ends in a slash or a dash keeps it.
assert detect_urls("https://example.com/dir/") == ["https://example.com/dir/"]
def test_detect_urls_ignores_non_http():
assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == []
assert detect_urls(None) == []
assert detect_urls("") == []
# --- checklist items: the body IS the checklist (M304) -----------------------
#
# The same table of cases as core/src/local/derive.rs. Deliberately duplicated
# rather than shared: the point of three implementations is that each is checked
# against the same grammar, and a test that only ran once would not catch the two
# drifting apart.
def test_parse_items_reads_a_list_out_of_prose():
body = "shopping\n\n- [ ] milk\n- [x] eggs"
assert [(i.text, i.checked) for i in parse_items(body)] == [("milk", False), ("eggs", True)]
def test_parse_items_between_paragraphs():
# The case a side table could not express, which is the whole reason for M304.
assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"]
@pytest.mark.parametrize(
"body",
[
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
],
)
def test_parse_items_rejects_near_misses(body):
assert parse_items(body) == []
def test_parse_items_accepts_star_bullets_and_indentation():
# `*` because markdown.ts already takes it for a plain bullet.
body = "* [ ] star\n - [x] indented"
assert [(i.text, i.checked) for i in parse_items(body)] == [("star", False), ("indented", True)]
def test_an_empty_item_is_still_an_item():
# What pressing Enter on a list leaves behind.
assert [i.text for i in parse_items("- [ ]")] == [""]
assert [i.text for i in parse_items("- [ ] ")] == [""]
def test_uppercase_x_parses_and_normalises_on_rewrite():
assert parse_items("- [X] done")[0].checked
assert set_item_checked("- [X] done", 0, True) == "- [x] done"
def test_rewriters_preserve_indent_bullet_and_neighbours():
assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk"
assert set_item_text("- [x] old", 0, "new") == "- [x] new"
assert remove_item("keep\n- [ ] drop\n- [ ] stay", 0) == "keep\n- [ ] stay"
# Addressed by ITEM, not by line.
assert set_item_checked("note\n- [ ] a\nprose\n- [ ] b", 1, True) == "note\n- [ ] a\nprose\n- [x] b"
def test_a_stale_index_does_nothing():
# The index comes from a client that may be a moment behind. A late request
# should be inert, not a 500.
body = "- [ ] only"
assert set_item_checked(body, 7, True) == body
assert remove_item(body, 7) == body
assert set_item_text(body, 7, "x") == body
def test_a_plain_body_is_returned_unchanged():
body = "just prose\nwith two lines"
assert set_item_checked(body, 0, True) == body
assert remove_item(body, 0) == body
def test_append_item_spacing():
# Prose, blank line, list — the layout _note_markdown has always exported, and
# what the migration folds existing rows into.
assert append_item("a note", "milk") == "a note\n\n- [ ] milk"
# Nothing between consecutive items.
assert append_item("a note\n\n- [ ] milk", "eggs") == "a note\n\n- [ ] milk\n- [ ] eggs"
# A list-only note starts at the first line.
assert append_item("", "milk") == "- [ ] milk"
assert append_item("\n\n", "milk") == "- [ ] milk"
# Carries state, which is what the migrations need of it.
assert append_item("", "done", True) == "- [x] done"
def test_strip_marker():
assert strip_marker("- [x] milk") == "milk"
assert strip_marker("just prose") == "just prose"
assert strip_marker("- [ ]") == ""
def test_the_migration_folds_exactly_like_the_app():
"""0027 inlines its own copy of append_item, deliberately — a migration has to keep
producing what it produced the day it ran, so it must not follow the app if the
app's spacing ever changes. This is what keeps the copy honest until then."""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0027_checklist_items_into_body.py"
spec = importlib.util.spec_from_file_location("_m0027", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for body, text, checked in [
("a note", "milk", False),
("a note\n\n- [ ] milk", "eggs", True),
("", "milk", False),
("\n\n", "milk", False),
("prose\n", " padded ", True),
]:
assert module._append_item(body, text, checked) == append_item(body, text, checked)
# --- import/export: the body already carries the list ------------------------
def test_note_markdown_writes_a_checklist_once():
"""The export used to append the items after the body. The body IS them now
(M304), so the old branch would have doubled every checklist in an export — and
doubled it again on the next re-import."""
note = Note(
display_title="shopping",
body="shopping\n\n- [ ] milk\n- [x] eggs",
pinned=False,
archived=False,
)
out = _note_markdown(note, [])
assert out.count("- [ ] milk") == 1
assert out.count("- [x] eggs") == 1
# And in place, under the note's own first line rather than in a block of its own.
assert out.rstrip().endswith("shopping\n\n- [ ] milk\n- [x] eggs")
def test_native_spec_of_a_current_export_folds_nothing():
# Today's export carries no `items` key, because the body has the lines. An empty
# list is what stops _insert_note folding them in a second time.
assert _native_spec({"body": "a\n\n- [ ] milk"})["items"] == []
def test_native_spec_of_a_pre_m304_export_still_carries_its_items():
# An export taken BEFORE this milestone has a body with no task lines and a
# separate items array. Importing one has to put the checklist back — which is
# the same fold the Keep importer does, and the reason _insert_note still accepts
# items at all.
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]