Fast, cross-device labelling: type #groceries in a note and it becomes the "groceries" label. The body is the source of truth for tag-labels; manual picker labels stay independent (rule 28 — additive). - note_labels.via_tag (migration 0013) marks tag-sourced attachments. - parse_tags(): #tag at start-of-body or after whitespace, needs a letter (so #2024, URL #frags, mid#word are ignored). unit-tested. - _reconcile_tags() on create + body-update: attach labels for current #tags (find-or-create, case-insensitive), detach tag-labels whose tag was removed; never touches manual (via_tag=false) rows. - label picker (set_note_labels + editor onLabelsChange) now preserves tag-labels on save, so a picker action can't strip a label the #tag still mandates. - serialize via_tag; card/editor chips render tag-labels as "#name", and the editor hides the × on them (remove by editing the tag text). - LabelPicker builds manual NoteLabels (via_tag:false). Fourth and final item of M4.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
182 lines
5.7 KiB
Python
182 lines
5.7 KiB
Python
import pytest
|
|
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.models.note import NOTE_COLORS, Note
|
|
from thoughtsync.notes import (
|
|
_escape_like,
|
|
derive_display_title,
|
|
is_empty_note,
|
|
normalize_color,
|
|
parse_link_titles,
|
|
parse_list_items,
|
|
parse_tags,
|
|
rewrite_link_title,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
def test_is_empty_note():
|
|
assert is_empty_note(None, None)
|
|
assert is_empty_note("", " ")
|
|
assert not is_empty_note("title", "")
|
|
assert not is_empty_note("", "body")
|
|
|
|
|
|
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():
|
|
for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"):
|
|
assert c in NOTE_COLORS
|
|
|
|
|
|
def test_serialize_shape():
|
|
n = Note(title="t", body="b", color="blue", pinned=True, archived=False)
|
|
s = n.serialize()
|
|
assert s["title"] == "t"
|
|
assert s["body"] == "b"
|
|
assert s["color"] == "blue"
|
|
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
|
|
|
|
|
|
def test_parse_link_titles():
|
|
titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again")
|
|
assert titles == ["alpha", "beta"]
|
|
|
|
|
|
def test_parse_link_titles_empty():
|
|
assert parse_link_titles(None) == []
|
|
assert parse_link_titles("no links here") == []
|
|
|
|
|
|
def test_rewrite_link_title():
|
|
body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]"
|
|
assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]"
|
|
|
|
|
|
def test_rewrite_link_title_noop():
|
|
assert rewrite_link_title("", "alpha", "Gamma") == ""
|
|
assert rewrite_link_title(None, "alpha", "Gamma") == ""
|
|
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here"
|
|
|
|
|
|
def test_derive_display_title_explicit_wins():
|
|
assert derive_display_title("My Title", "some body line") == "My Title"
|
|
assert derive_display_title(" Padded ", "body") == "Padded"
|
|
|
|
|
|
def test_derive_display_title_from_first_body_line():
|
|
assert derive_display_title(None, "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(None, "\n \nreal line\nmore") == "real line"
|
|
# a whitespace-only title falls through to the body
|
|
assert derive_display_title(" ", "body wins") == "body wins"
|
|
|
|
|
|
def test_derive_display_title_empty():
|
|
assert derive_display_title(None, None) == ""
|
|
assert derive_display_title("", "") == ""
|
|
assert derive_display_title(" ", " \n ") == ""
|
|
|
|
|
|
def test_derive_display_title_caps_length():
|
|
long = "x" * 300
|
|
assert derive_display_title(None, long) == "x" * 200
|
|
assert derive_display_title(long, "body") == "x" * 200
|
|
|
|
|
|
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_escape_like():
|
|
# LIKE wildcards in user input must be neutralized so they match literally.
|
|
assert _escape_like("100%") == "100\\%"
|
|
assert _escape_like("a_b") == "a\\_b"
|
|
assert _escape_like("c:\\path") == "c:\\\\path"
|
|
assert _escape_like("plain") == "plain"
|
|
|
|
|
|
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_link_search_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.get("/api/notes/link-search?q=hi")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_graph_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.get("/api/graph")
|
|
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
|