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 = ( 'Image: Photo.png' ) 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 = ( '' ) 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": "Link"})): 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": "Link"})): 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": ''}, {"html_fragment": 'x'}, {"html_fragment": image_rich_text()["html_fragment"].replace(" alt=", ' onclick="run()" alt=')}, {"html_fragment": image_rich_text()["html_fragment"] + ""}, 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()