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 = (
'
'
)
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": '
'},
{"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()