ClipmanLinux/test_content_features.py
Rama main · réplica de lectura del repositorio original.
import base64
import binascii
import hashlib
import json
import os
import pathlib
import struct
import tempfile
import time
import unittest
import zlib
from unittest import mock
import clipman
def png_chunk(kind, data):
payload = kind + data
return struct.pack(">I", len(data)) + payload + struct.pack(">I", binascii.crc32(payload) & 0xffffffff)
def small_png():
scanline = b"\x00\xff\x00\x00\xff"
return (
b"\x89PNG\r\n\x1a\n"
+ png_chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0))
+ png_chunk(b"IDAT", zlib.compress(scanline))
+ png_chunk(b"IEND", b"")
)
def small_jpeg_header():
return bytes.fromhex("ffd8ffc000070800010001")
def image_rich_text(data=None, mime_type="image/png"):
data = data or small_png()
wrapper = (
'<img data-clipman-image="1" data-clipman-filename="Photo.png" alt="Image: Photo.png" '
'src="data:' + mime_type + ';base64,' + base64.b64encode(data).decode("ascii") + '">'
)
return {"html_fragment": wrapper, "preferred_format": "Html"}
def image_rich_text_with_filename(filename, data=None, mime_type="image/png"):
data = data or small_png()
wrapper = (
'<img data-clipman-image="1" data-clipman-filename="' + filename + '" '
'alt="Image: ' + filename + '" src="data:' + mime_type + ';base64,'
+ base64.b64encode(data).decode("ascii") + '">'
)
return {"html_fragment": wrapper, "preferred_format": "Html"}
class LinkLabelTests(unittest.TestCase):
def test_image_entry_edit_preserves_payload_identity_and_template_state(self):
entry = {
"text": "Image: Photo.png (stable-id)",
"is_template": False,
"rich_text": image_rich_text(),
}
text, is_template = clipman.protect_image_entry_edit(
entry,
"Image: Photo.png altered",
True,
)
self.assertEqual(text, entry["text"])
self.assertFalse(is_template)
def test_entry_editor_save_shortcut_requires_exact_control_enter(self):
self.assertTrue(clipman.dialog_save_shortcut(clipman.Gdk.KEY_Return, clipman.Gdk.ModifierType.CONTROL_MASK))
self.assertTrue(clipman.dialog_save_shortcut(clipman.Gdk.KEY_KP_Enter, clipman.Gdk.ModifierType.CONTROL_MASK))
self.assertFalse(clipman.dialog_save_shortcut(clipman.Gdk.KEY_Return, 0))
self.assertFalse(clipman.dialog_save_shortcut(
clipman.Gdk.KEY_Return,
clipman.Gdk.ModifierType.CONTROL_MASK | clipman.Gdk.ModifierType.SHIFT_MASK,
))
def test_open_link_accepts_only_standalone_web_links(self):
self.assertEqual(
clipman.openable_web_link(" https://example.com/path "),
"https://example.com/path",
)
self.assertEqual(
clipman.openable_web_link("https://example.com/path link"),
"https://example.com/path",
)
self.assertIsNone(clipman.openable_web_link("Read https://example.com/path"))
self.assertIsNone(clipman.openable_web_link("clipman://example.com/path"))
def test_name_and_content_copy_formatting(self):
entries = [
{"name": " Release notes ", "text": "https://example.com/release"},
{"name": "", "text": "Unnamed text"},
]
self.assertEqual(
clipman.name_and_content_text(entries),
"Release notes\nhttps://example.com/release\n\nUnnamed text",
)
self.assertEqual(
clipman.name_and_content_text(entries, ["Resolved first", "Resolved second"]),
"Release notes\nResolved first\n\nResolved second",
)
self.assertEqual(
clipman.name_and_content_text(entries, separator="\n"),
"Release notes\nhttps://example.com/release\nUnnamed text",
)
self.assertEqual(clipman.decode_multiple_entry_separator("none", "ignored"), "")
self.assertEqual(clipman.decode_multiple_entry_separator("custom", "\\n--\\t"), "\n--\t")
def test_desktop_status_combines_connection_state_and_active_count(self):
app = clipman.ClipmanApplication.__new__(clipman.ClipmanApplication)
app.section = "links"
app.entries = [
{"section": "links", "rich_text": None},
{"section": "links", "rich_text": None},
{"section": "text", "rich_text": None},
]
app.file_events = []
app.offline = False
app.preferences = mock.Mock(values={
"links_history_enabled": True,
"rich_text_history_enabled": False,
})
app.visible_entries = lambda: app.entries[:1]
self.assertEqual(
app._steady_status_text(),
"Showing 1 of 2 link entries. Ready. Server sync connected.",
)
app.section = "files"
app.file_events = [{"id": "one"}]
app.visible_entries = lambda: app.file_events
app.offline = True
self.assertEqual(
app._steady_status_text(),
"1 file history event. Offline. Using read-only cache.",
)
def test_name_wins_and_destination_omits_scheme(self):
label, destination = clipman.link_display_parts(
"https://www.ableton.com/en/release-notes/move-1-beta/?tracking=ignored",
"Move beta notes",
)
self.assertEqual(label, "Move beta notes")
self.assertEqual(destination, "ableton.com/en/release-notes/move-1-beta/")
self.assertEqual(
clipman.link_row_text("https://example.com/path", "Doug proposal"),
"Doug proposal; example.com/path",
)
self.assertEqual(
clipman.entry_display_text({
"section": "links", "text": "https://example.com/path", "name": "Doug proposal",
}),
"Doug proposal; example.com/path",
)
def test_generated_label_uses_host_and_meaningful_segment(self):
label, destination = clipman.link_display_parts(
"https://example.com/articles/accessible-clipboard-management.html"
)
self.assertEqual(label, "Accessible clipboard management")
self.assertEqual(destination, "example.com/articles/accessible-clipboard-management.html")
def test_download_link_provides_persistent_fallback_name(self):
label, destination = clipman.link_display_parts(
"https://3.onj.me/bbcip/Inside%20No.%209.7z"
)
self.assertEqual(label, "Inside No. 9.7z")
self.assertEqual(destination, "3.onj.me/bbcip/Inside No. 9.7z")
def test_numbered_resource_and_root_labels_are_concise(self):
self.assertEqual(
clipman.link_row_text("https://github.com/OnjLouis/Clipman/issues/50"),
"Issues 50; github.com/OnjLouis/Clipman/issues/50",
)
self.assertEqual(
clipman.link_row_text("https://example.com/2024/03/how-to-fix-the-thing"),
"How to fix the thing; example.com/2024/03/how-to-fix-the-thing",
)
self.assertEqual(clipman.link_row_text("https://www.example.com/"), "example.com")
def test_uuid_and_high_entropy_segments_are_skipped(self):
label, _destination = clipman.link_display_parts(
"https://example.com/articles/550e8400-e29b-41d4-a716-446655440000"
)
self.assertEqual(label, "Articles")
label, _destination = clipman.link_display_parts(
"https://example.com/download/aB91kLm302PQrsTUvWxyZ678"
)
self.assertEqual(label, "Download")
def test_website_title_disclosure_names_the_sent_request(self):
self.assertEqual(
clipman.website_title_disclosure("example.com"),
"Clipman will contact example.com once to read the page title. "
"The website can see that it was contacted. Clipman sends the selected link request, "
"but no cookies, credentials or other clipboard content.",
)
def test_exact_urls_use_link_presentation_in_every_text_section(self):
for section, rich_text in (("text", None), ("links", None), ("rich", {"html_fragment": "<b>Link</b>"})):
entry = {
"section": section,
"text": "https://github.com/OnjLouis/Clipman/issues/50",
"name": "",
"display": "https://github.com/OnjLouis/Clipman/issues/50",
"rich_text": rich_text,
}
self.assertEqual(
clipman.entry_display_text(entry),
"Issues 50; github.com/OnjLouis/Clipman/issues/50",
)
searchable = clipman.entry_search_text(entry)
self.assertIn("Issues 50", searchable)
self.assertIn("https://github.com/OnjLouis/Clipman/issues/50", searchable)
self.assertTrue(clipman.entry_display_text(entry).startswith("Issues 50"))
def test_website_title_eligibility_is_entry_based_not_section_based(self):
for section, rich_text in (("text", None), ("links", None), ("rich", {"html_fragment": "<b>Link</b>"})):
entry = {"section": section, "text": "https://example.com/article", "name": "", "rich_text": rich_text}
self.assertTrue(clipman.can_use_website_title(entry))
self.assertFalse(clipman.can_use_website_title({"section": "text", "text": "Read https://example.com", "name": ""}))
self.assertFalse(clipman.can_use_website_title({"section": "links", "text": "https://example.com", "name": "Existing"}))
def test_trailing_screen_reader_link_role_is_still_one_link(self):
stored = "https://youtu.be/Mu1gjX5fMA4 link"
self.assertTrue(clipman.is_standalone_link(stored))
self.assertEqual(
clipman.link_row_text(stored, "Wooden Flute"),
"Wooden Flute; youtu.be/Mu1gjX5fMA4",
)
self.assertFalse(clipman.is_standalone_link("Read " + stored))
def test_overlong_urls_are_rejected_before_link_parsing_or_unescaping(self):
prefix = "https://example.com/"
exact = prefix + "a" * (clipman.MAX_LINK_URL_CHARACTERS - len(prefix))
self.assertIsNotNone(clipman._web_link(exact))
value = exact + "a"
with mock.patch("urllib.parse.urlsplit", side_effect=AssertionError("URL parser was called")):
self.assertIsNone(clipman._web_link(value))
self.assertFalse(clipman.is_standalone_link(value))
self.assertEqual(clipman.link_display_parts(value), ("Link", "Link"))
self.assertEqual(clipman.clean_tracking_url(value), value)
def test_offline_link_labels_strip_unsafe_unicode_consistently(self):
label, destination = clipman.link_display_parts(
"https://example.com/alpha%0Abeta%E2%80%8Dgamma%EF%BF%BDdelta",
" Named\tentry\u200d\ufffd\u2028\u2029 ",
)
self.assertEqual(label, "Namedentry")
self.assertEqual(destination, "example.com/alphabetagammadelta")
self.assertEqual(
clipman._clean_link_text("Alpha\nBeta\u200dGamma\ufffdDelta\u2028Epsilon\u2029Zeta\u00a0 Eta", 200),
"AlphaBetaGammaDeltaEpsilonZeta Eta",
)
self.assertEqual(clipman._clean_link_text("Good\ud800Title"), "GoodTitle")
class SensitiveDataTests(unittest.TestCase):
def test_explicit_web_urls_do_not_trigger_token_exclusion(self):
for value in (
"https://example.com/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456",
"HTTPS://example.com/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456",
):
self.assertEqual(clipman.sensitive_data_match(value, ["api-token"]), "")
def test_inferred_links_do_not_bypass_sensitive_data_detection(self):
self.assertEqual(
clipman.sensitive_data_match("+447890123456", ["international-phone"]),
"International phone number",
)
self.assertEqual(
clipman.sensitive_data_match("ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", ["api-token"]),
"Long API key or token",
)
class FileClipboardURLTests(unittest.TestCase):
def test_web_uri_list_is_a_link_without_changing_file_uris(self):
web = "https://example.com/article?from=browser"
self.assertEqual(clipman.parse_file_clipboard_payload(web + "\r\n"), ([], "Copy"))
self.assertEqual(clipman.web_url_from_uri_list(web + "\r\n"), web)
self.assertEqual(clipman.web_url_from_uri_list("# copied from browser\r\n" + web + "\r\n"), web)
self.assertIsNone(clipman.web_url_from_uri_list("file:///home/user/note.txt\r\n"))
self.assertIsNone(clipman.web_url_from_uri_list(web + "\r\nfile:///home/user/note.txt\r\n"))
def test_uri_list_callback_routes_web_links_and_local_files_separately(self):
def dispatch(payload, force=False):
app = mock.Mock()
app.clipboard_read_busy = True
stream = mock.Mock()
stream.read_bytes_finish.return_value.get_data.return_value = payload.encode("utf-8")
clipboard = object()
clipman.ClipmanApplication._clipboard_file_payload_read(
app, stream, None, (clipboard, "text/uri-list", force)
)
return app, clipboard
web = "https://example.com/article"
app, clipboard = dispatch(web + "\r\n")
app._process_clipboard_text.assert_called_once_with(clipboard, web, None)
app._capture_file_paths.assert_not_called()
app, _ = dispatch(web + "\r\n", force=True)
app._put_text.assert_called_once_with(web)
app, clipboard = dispatch("file:///home/user/note.txt\r\n")
app._capture_file_paths.assert_called_once_with(
clipboard, ["/home/user/note.txt"], "Copy", "text/uri-list", False
)
class EmbeddedImageTests(unittest.TestCase):
def test_wrapper_recognition_and_native_clipboard_payload(self):
rich_text = image_rich_text()
image = clipman.parse_clipman_image(rich_text)
self.assertIsNotNone(image)
self.assertEqual((image["width"], image["height"]), (1, 1))
self.assertEqual(image["filename"], "Photo.png")
entry = {"text": "Image: Photo.png (0123456789ab)", "name": "", "rich_text": rich_text, "display": "stored"}
self.assertEqual(clipman.entry_display_text(entry), "Image: Photo.png")
entry["name"] = "Invoice photograph"
self.assertEqual(clipman.entry_display_text(entry), "Invoice photograph")
payloads = dict(clipman.clipboard_payloads("Image: Photo.png", rich_text))
self.assertEqual(payloads["image/png"], small_png())
self.assertIn(b"data-clipman-image", payloads["text/html"])
fallback = "Image: Photo.png (" + hashlib.sha256(small_png()).hexdigest()[:12] + ")"
payloads = dict(clipman.clipboard_payloads(fallback, rich_text))
self.assertEqual(payloads["text/plain"], fallback.encode("utf-8"))
def test_apostrophe_filename_uses_shared_canonical_escaping(self):
rich_text = image_rich_text_with_filename("Andre's photo.png")
image = clipman.parse_clipman_image(rich_text)
self.assertIsNotNone(image)
self.assertEqual(image["filename"], "Andre's photo.png")
self.assertIn("Andre's photo.png", image["html"])
self.assertNotIn("'", image["html"])
noncanonical = {"html_fragment": image["html"].replace("Andre's", "Andre's")}
self.assertIsNone(clipman.parse_clipman_image(noncanonical))
def test_filename_length_and_extension_must_be_canonical(self):
self.assertIsNotNone(clipman.parse_clipman_image(image_rich_text_with_filename("a" * 116 + ".png")))
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename("a" * 117 + ".png")))
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename("Photo.jpg")))
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename("private:Photo.png")))
jpeg = small_jpeg_header()
self.assertIsNotNone(clipman.parse_clipman_image(image_rich_text_with_filename("Photo.jpg", jpeg, "image/jpeg")))
self.assertIsNotNone(clipman.parse_clipman_image(image_rich_text_with_filename("Photo.jpeg", jpeg, "image/jpeg")))
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename("Photo.png", jpeg, "image/jpeg")))
def test_filename_limit_uses_unicode_scalars_and_rejects_unsafe_characters(self):
self.assertIsNotNone(clipman.parse_clipman_image(image_rich_text_with_filename("\U0001f4f7" * 116 + ".png")))
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename("\U0001f4f7" * 117 + ".png")))
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename("Photo.PNG")))
self.assertEqual(clipman._canonical_image_filename("Photo.PNG", "image/png"), "Photo.png")
self.assertEqual(
clipman._canonical_image_filename(" photo\t\u00a0\u2003\u2028\u2029note.PNG ", "image/png"),
"photo note.png",
)
self.assertEqual(clipman._canonical_image_filename("photo\u001cnote.PNG", "image/png"), "photonote.png")
for filename in ("bad\x01name.png", "bad\u200fname.png", "bad\u202ename.png", "bad\u2028name.png", "bad\ud800name.png", "two spaces.png"):
self.assertIsNone(clipman.parse_clipman_image(image_rich_text_with_filename(filename)))
def test_external_active_and_noncanonical_images_are_rejected(self):
invalid = [
{"html_fragment": '<img src="https://example.com/photo.png">'},
{"html_fragment": '<img data-clipman-image="1" data-clipman-filename="x.svg" alt="x" src="data:image/svg+xml;base64,AAAA">'},
{"html_fragment": image_rich_text()["html_fragment"].replace(" alt=", ' onclick="run()" alt=')},
{"html_fragment": image_rich_text()["html_fragment"] + "<script>run()</script>"},
image_rich_text_with_filename("/home/user/private/Photo.png"),
image_rich_text_with_filename("content:provider/photo.png"),
]
for rich_text in invalid:
self.assertIsNone(clipman.parse_clipman_image(rich_text))
def test_stored_size_limit_is_enforced(self):
oversized = small_png() + b"\x00" * clipman.MAX_STORED_IMAGE_BYTES
self.assertIsNone(clipman.parse_clipman_image(image_rich_text(oversized)))
def test_animation_marker_must_be_a_real_png_chunk(self):
ordinary = small_png()[:-12] + png_chunk(b"tEXt", b"word=acTL") + small_png()[-12:]
self.assertIsNotNone(clipman.parse_clipman_image(image_rich_text(ordinary)))
animated = small_png()[:-12] + png_chunk(b"acTL", struct.pack(">II", 1, 0)) + small_png()[-12:]
self.assertIsNone(clipman.parse_clipman_image(image_rich_text(animated)))
def test_file_clipboard_name_uses_capture_date_device_and_original_format(self):
entry = {"created_unix_ms": 1_700_000_000_000, "device": 'Phone/One: "Photo"'}
image = {"filename": "Original.jpeg", "mime": "image/jpeg", "data": small_jpeg_header()}
with mock.patch("time.localtime", return_value=time.struct_time((2023, 11, 14, 22, 13, 20, 1, 318, 0))):
filename = clipman.image_clipboard_filename(entry, image)
self.assertEqual(filename, "2023-11-14 22-13-20 - PhoneOne Photo.jpeg")
def test_file_clipboard_payload_uses_gnome_and_uri_list_contracts(self):
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "Photo with space.png")
payloads = dict(clipman.image_file_clipboard_payloads(path))
self.assertEqual(payloads["x-special/gnome-copied-files"], b"copy\nfile://" + path.replace(" ", "%20").encode("utf-8"))
self.assertEqual(payloads["x-special/mate-copied-files"], b"copy\nfile://" + path.replace(" ", "%20").encode("utf-8"))
self.assertEqual(payloads["text/uri-list"], b"file://" + path.replace(" ", "%20").encode("utf-8") + b"\r\n")
def test_file_clipboard_date_rejects_missing_invalid_and_future_values(self):
self.assertIsNone(clipman._image_clipboard_timestamp(None, now=1_700_000_000))
self.assertIsNone(clipman._image_clipboard_timestamp({"created_unix_ms": "bad"}, now=1_700_000_000))
self.assertIsNone(clipman._image_clipboard_timestamp({"created_unix_ms": 1_700_172_800_000}, now=1_700_000_000))
self.assertEqual(
clipman._image_clipboard_timestamp({"created_unix_ms": 1_700_000_000_000}, now=1_700_000_000),
1_700_000_000,
)
def test_clipboard_image_cache_preserves_bytes_and_removes_previous_file(self):
with tempfile.TemporaryDirectory() as folder:
stale_directory = pathlib.Path(folder) / "clipman-linux" / "clipboard-images"
stale_directory.mkdir(parents=True)
stale_file = stale_directory / "stale.png"
stale_file.write_bytes(b"stale")
cache = clipman.ClipboardImageFileCache(folder)
self.assertFalse(stale_file.exists())
first = {"filename": "Photo.png", "mime": "image/png", "data": small_png()}
first_path = cache.materialize(first, {"created_unix_ms": 1_700_000_000_000, "device": "Phone"})
self.assertEqual(first_path.read_bytes(), small_png())
self.assertEqual(first_path.stat().st_mode & 0o777, 0o600)
self.assertAlmostEqual(first_path.stat().st_mtime, 1_700_000_000, delta=1)
second_data = small_jpeg_header()
second = {"filename": "Photo.jpg", "mime": "image/jpeg", "data": second_data}
second_path = cache.materialize(second, {"created_unix_ms": 2_000, "device": "Tablet"})
self.assertFalse(first_path.exists())
self.assertEqual(second_path.read_bytes(), second_data)
self.assertEqual([path.name for path in cache.directory.iterdir()], [second_path.name])
cache.clear()
self.assertFalse(second_path.exists())
def test_local_image_file_candidate_and_bounded_reader_preserve_bytes(self):
with tempfile.TemporaryDirectory() as folder:
root = pathlib.Path(folder)
png = root / "Camera metadata.PNG"
original = small_png()[:-12] + png_chunk(b"tEXt", b"location=retained") + small_png()[-12:]
png.write_bytes(original)
path, mime_type = clipman.local_image_file_candidate([str(png)])
self.assertEqual((path, mime_type), (str(png), "image/png"))
self.assertEqual(clipman.read_bounded_local_image_file(path), original)
jpeg = root / "Photo.JPEG"
jpeg.write_bytes(small_jpeg_header())
self.assertEqual(clipman.local_image_file_candidate([str(jpeg)]), (str(jpeg), "image/jpeg"))
def test_local_image_file_candidate_rejects_unsupported_multiple_and_directories(self):
with tempfile.TemporaryDirectory() as folder:
root = pathlib.Path(folder)
png = root / "photo.png"
png.write_bytes(small_png())
text = root / "notes.txt"
text.write_text("not an image", encoding="utf-8")
for paths, message in (
([], "does not contain a local file"),
([str(png), str(text)], "multiple files"),
([str(root)], "Folders cannot"),
([str(text)], "Only local PNG and JPEG"),
):
with self.subTest(paths=paths), self.assertRaisesRegex(ValueError, message):
clipman.local_image_file_candidate(paths)
empty = root / "empty.png"
empty.touch()
with self.assertRaisesRegex(ValueError, "empty"):
clipman.read_bounded_local_image_file(empty)
oversized = root / "large.jpg"
oversized.write_bytes(b"12345")
with self.assertRaisesRegex(ValueError, "larger"):
clipman.read_bounded_local_image_file(oversized, 4)
def test_explicit_and_automatic_file_image_policy_are_independent(self):
values = {
"rich_text_history_enabled": True,
"include_images_in_rich_text_history": True,
"add_copied_image_files_to_rich_text_history": False,
}
self.assertTrue(clipman.should_import_file_as_rich_image(values, "rich", True))
self.assertFalse(clipman.should_import_file_as_rich_image(values, "text", True))
self.assertFalse(clipman.should_import_file_as_rich_image(values, "rich", False))
values["add_copied_image_files_to_rich_text_history"] = True
self.assertTrue(clipman.should_import_file_as_rich_image(values, "text", False))
values["include_images_in_rich_text_history"] = False
self.assertFalse(clipman.should_import_file_as_rich_image(values, "rich", True))
self.assertFalse(clipman.should_import_file_as_rich_image(values, "text", False))
class ClipMergeTests(unittest.TestCase):
@staticmethod
def observation(kind="text", signature="value", source="Writer", operation="", history_id="", values=None):
return {
"kind": kind, "signature": signature, "source": source,
"operation": operation, "history_id": history_id,
"values": list(values) if values is not None else [signature],
}
def test_requires_matching_second_change_from_same_application(self):
detector = clipman.ClipMergeDetector()
self.assertIsNone(detector.observe(self.observation(signature="A", history_id="a"), 1000, True, 500, False))
detector.set_current_history_id("a")
self.assertIsNone(detector.observe(self.observation(signature="B", history_id="b"), 2000, True, 500, False))
base, first = detector.observe(self.observation(signature="B"), 2450, True, 500, False)
self.assertEqual((base["history_id"], first["history_id"]), ("a", "b"))
detector.reset()
detector.observe(self.observation(signature="A"), 1000, True, 500, False)
detector.observe(self.observation(signature="B"), 2000, True, 500, False)
self.assertIsNone(detector.observe(self.observation(signature="B", source="Browser"), 2200, True, 500, False))
def test_rejects_mixed_types_operations_and_deliberate_capture(self):
detector = clipman.ClipMergeDetector()
detector.observe(self.observation(kind="files", signature="base", operation="cut"), 1000, True, 500, False)
detector.observe(self.observation(kind="files", signature="next", operation="copy"), 2000, True, 500, False)
self.assertIsNone(detector.observe(self.observation(kind="files", signature="next", operation="copy"), 2200, True, 500, False))
self.assertIsNone(detector.observe(self.observation(signature="value"), 2300, True, 500, True))
def test_coalesces_duplicates_and_rejects_stale_cut_sources(self):
detector = clipman.ClipMergeDetector()
detector.observe(self.observation(signature="A", history_id="a"), 1000, True, 500, False)
detector.set_current_history_id("a")
detector.observe(self.observation(signature="B", history_id="b"), 2000, True, 500, False)
detector.set_current_history_id("b")
duplicate = detector.observe(self.observation(signature="B"), 2040, True, 500, False)
self.assertIs(duplicate, clipman.ClipMergeDetector.DUPLICATE_NOTIFICATION)
self.assertIsNotNone(detector.observe(self.observation(signature="B"), 2200, True, 500, False))
detector.reset()
detector.observe(self.observation(signature="A", source="Thunderbird", history_id="a"), 1000, True, 1000, False)
detector.set_current_history_id("a")
detector.observe(self.observation(signature="B", source="Thunderbird", history_id="b"), 2000, True, 1000, False)
detector.set_current_history_id("b")
mozilla_duplicate = detector.observe(self.observation(signature="B", source="Thunderbird"), 2250, True, 1000, False)
self.assertIs(mozilla_duplicate, clipman.ClipMergeDetector.DUPLICATE_NOTIFICATION)
self.assertIsNotNone(detector.observe(self.observation(signature="B", source="Thunderbird"), 2800, True, 1000, False))
with tempfile.TemporaryDirectory() as directory:
old_path = os.path.join(directory, "old.txt")
new_path = os.path.join(directory, "new.txt")
pathlib.Path(old_path).write_text("old", encoding="utf-8")
pathlib.Path(new_path).write_text("new", encoding="utf-8")
detector.reset()
detector.observe(self.observation(kind="files", signature="old", operation="move", values=[old_path]), 3000, True, 500, False)
duplicate_cut = detector.observe(self.observation(kind="files", signature="old", operation="move", values=[old_path]), 3040, True, 500, False)
self.assertIs(duplicate_cut, clipman.ClipMergeDetector.DUPLICATE_NOTIFICATION)
detector.observe(self.observation(kind="files", signature="new", operation="move", values=[new_path]), 4000, True, 500, False)
live_cut_merge = detector.observe(self.observation(kind="files", signature="new", operation="move", values=[new_path]), 4300, True, 500, False)
self.assertIsNotNone(live_cut_merge)
self.assertTrue(clipman.ClipMergeDetector.sources_available([old_path, new_path]))
os.remove(old_path)
self.assertFalse(clipman.ClipMergeDetector.sources_available([old_path, new_path]))
detector.retain_first(live_cut_merge[1])
def test_defaults_and_separator_bounds_are_conservative(self):
self.assertEqual(clipman.ClipMergeDetector.normalize_window(1), 200)
self.assertEqual(clipman.ClipMergeDetector.normalize_window(9999), 2000)
self.assertEqual(clipman.ClipMergeDetector.separator("newline", ""), "\n")
self.assertEqual(clipman.ClipMergeDetector.separator("custom", r"\n--\t"), "\n--\t")
class PreferenceTests(unittest.TestCase):
def test_quick_clip_global_hotkey_defaults_to_unassigned(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": folder}):
preferences = clipman.Preferences()
self.assertEqual(preferences.values["quick_clip_hotkey"], "")
def test_website_title_contact_defaults_are_conservative(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": folder}):
preferences = clipman.Preferences()
self.assertTrue(preferences.values["confirm_website_title_requests"])
self.assertFalse(preferences.values["auto_name_copied_website_links"])
def test_image_capture_is_cleared_when_rich_text_is_disabled(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": folder}):
settings = os.path.join(folder, "clipman-linux", "settings.json")
os.makedirs(os.path.dirname(settings))
with open(settings, "w", encoding="utf-8") as stream:
json.dump({"rich_text_history_enabled": False, "include_images_in_rich_text_history": True}, stream)
preferences = clipman.Preferences()
self.assertFalse(preferences.values["include_images_in_rich_text_history"])
self.assertFalse(preferences.values["add_copied_image_files_to_rich_text_history"])
payloads = dict(clipman.clipboard_payloads("Image: Photo.png", image_rich_text()))
self.assertEqual(payloads["image/png"], small_png())
def test_copied_image_file_preference_persists_only_with_both_parents(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": folder}):
settings = pathlib.Path(folder) / "clipman-linux" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({
"rich_text_history_enabled": True,
"include_images_in_rich_text_history": True,
"add_copied_image_files_to_rich_text_history": True,
}), encoding="utf-8")
preferences = clipman.Preferences()
self.assertTrue(preferences.values["add_copied_image_files_to_rich_text_history"])
preferences.save()
self.assertTrue(json.loads(settings.read_text(encoding="utf-8"))["add_copied_image_files_to_rich_text_history"])
settings.write_text(json.dumps({
"rich_text_history_enabled": True,
"include_images_in_rich_text_history": False,
"add_copied_image_files_to_rich_text_history": True,
}), encoding="utf-8")
preferences = clipman.Preferences()
self.assertFalse(preferences.values["add_copied_image_files_to_rich_text_history"])
def test_image_metadata_disclosure_explains_privacy_tradeoff(self):
self.assertIn("camera or location information", clipman.IMAGE_METADATA_DISCLOSURE)
self.assertIn("history encryption and sync choices", clipman.IMAGE_METADATA_DISCLOSURE)
if __name__ == "__main__":
unittest.main()