The profile folder classifier now puts Matrix and SimpleX next to Signal and Viber in «Мессенджеры» instead of «Сайты». Dev 21.1.7.213 already placed them in «Сайты» for users who got it: the existing split mechanism moves such a profile once, only if the user never arranged it by hand. Tests: the classifier and the one-time move (fail on the old code). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
19 KiB
Python
382 lines
19 KiB
Python
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from tempfile import TemporaryDirectory
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
from folders.defaults import COMMON_FOLDER_KEY
|
||
from folders.ordering import plan_item_move
|
||
from profile.folders import (
|
||
create_profile_folder,
|
||
delete_profile_folder,
|
||
load_profile_folder_state,
|
||
move_profile_folder_by_step,
|
||
move_untouched_profiles_to_split_folders,
|
||
rename_profile_folder,
|
||
reset_profile_folders,
|
||
set_profile_folder_collapsed,
|
||
set_profile_folders_collapsed,
|
||
save_profile_folder_state,
|
||
)
|
||
|
||
|
||
def _live_items(*keys: str) -> list[dict]:
|
||
return [{"key": key, "name": key} for key in keys]
|
||
|
||
|
||
class ProfileFolderActionTests(unittest.TestCase):
|
||
def test_create_profile_folder_places_it_after_common(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
folder_key = create_profile_folder("Моя папка")
|
||
state = load_profile_folder_state()
|
||
|
||
ordered_names = [
|
||
folder["name"]
|
||
for _key, folder in sorted(state["folders"].items(), key=lambda pair: pair[1]["order"])
|
||
]
|
||
self.assertEqual(folder_key, "моя-папка")
|
||
self.assertEqual(ordered_names[-3:], ["Общие", "Моя папка", "Все сайты"])
|
||
|
||
def test_delete_profile_folder_moves_profiles_to_common(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
folder_key = create_profile_folder("Моя папка")
|
||
state = load_profile_folder_state()
|
||
state["items"]["youtube.com (интерфейс)"] = {"folder_key": folder_key, "order": 0, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
self.assertTrue(delete_profile_folder(folder_key))
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertNotIn(folder_key, state["folders"])
|
||
self.assertEqual(state["items"]["youtube.com (интерфейс)"]["folder_key"], COMMON_FOLDER_KEY)
|
||
|
||
def test_common_profile_folder_cannot_be_renamed_but_can_move(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
self.assertFalse(rename_profile_folder(COMMON_FOLDER_KEY, "Другая"))
|
||
self.assertTrue(move_profile_folder_by_step(COMMON_FOLDER_KEY, -1))
|
||
state = load_profile_folder_state()
|
||
|
||
ordered_names = [
|
||
folder["name"]
|
||
for _key, folder in sorted(state["folders"].items(), key=lambda pair: pair[1]["order"])
|
||
]
|
||
self.assertIn("Общие", ordered_names)
|
||
self.assertLess(ordered_names.index("Общие"), ordered_names.index("ZapretKVN"))
|
||
|
||
def test_duplicate_profile_folder_rename_skips_folder_state_save(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
folder_key = create_profile_folder("Моя папка")
|
||
with patch(
|
||
"profile.folders.save_profile_folder_state",
|
||
side_effect=AssertionError("unchanged profile folder name must not be saved"),
|
||
):
|
||
self.assertFalse(rename_profile_folder(folder_key, "Моя папка"))
|
||
|
||
def test_profile_folder_collapsed_and_reset_are_saved_in_settings(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
self.assertTrue(set_profile_folder_collapsed("youtube", True))
|
||
state = load_profile_folder_state()
|
||
self.assertTrue(state["folders"]["youtube"]["collapsed"])
|
||
|
||
reset_profile_folders()
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertFalse(state["folders"]["youtube"]["collapsed"])
|
||
|
||
def test_profile_folder_reset_always_returns_fresh_state_without_redundant_write(self) -> None:
|
||
# Reset всегда возвращает свежее состояние (UI обязан перерисоваться),
|
||
# но фактическая запись settings при неизменном состоянии не происходит.
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
load_profile_folder_state()
|
||
with patch(
|
||
"profile.folders.settings_store.set_folders_settings",
|
||
side_effect=AssertionError("unchanged profile folder state must not write settings"),
|
||
):
|
||
state = reset_profile_folders()
|
||
self.assertIsInstance(state, dict)
|
||
self.assertIn("youtube", state["folders"])
|
||
|
||
def test_untouched_voice_profile_moves_from_discord_to_its_new_folder(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
state["items"]["uid:voice"] = {"folder_key": "discord", "order": None, "rating": 4}
|
||
state["items"]["uid:discord"] = {"folder_key": "discord", "order": None, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
self.assertTrue(
|
||
move_untouched_profiles_to_split_folders({"uid:voice": "voice", "uid:discord": "discord"})
|
||
)
|
||
state = load_profile_folder_state()
|
||
# Повторный вызов ничего не меняет и в настройки не пишет.
|
||
with patch(
|
||
"profile.folders.settings_store.set_folders_settings",
|
||
side_effect=AssertionError("повторный перенос не должен писать настройки"),
|
||
):
|
||
self.assertFalse(move_untouched_profiles_to_split_folders({"uid:voice": "voice"}))
|
||
|
||
self.assertEqual(state["items"]["uid:voice"], {"folder_key": "voice", "order": None, "rating": 4})
|
||
self.assertEqual(state["items"]["uid:discord"]["folder_key"], "discord")
|
||
|
||
def test_untouched_telegram_and_whatsapp_leave_the_shared_messengers_folder(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
for key in ("uid:telegram", "uid:whatsapp", "uid:viber"):
|
||
state["items"][key] = {"folder_key": "messengers", "order": None, "rating": 0}
|
||
# Этот WhatsApp пользователь уже двигал внутри «Мессенджеров».
|
||
state["items"]["uid:whatsapp-ordered"] = {"folder_key": "messengers", "order": 0, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
self.assertTrue(
|
||
move_untouched_profiles_to_split_folders(
|
||
{
|
||
"uid:telegram": "telegram",
|
||
"uid:whatsapp": "whatsapp",
|
||
"uid:viber": "messengers",
|
||
"uid:whatsapp-ordered": "whatsapp",
|
||
}
|
||
)
|
||
)
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertEqual(state["items"]["uid:telegram"]["folder_key"], "telegram")
|
||
self.assertEqual(state["items"]["uid:whatsapp"]["folder_key"], "whatsapp")
|
||
self.assertEqual(state["items"]["uid:viber"]["folder_key"], "messengers")
|
||
self.assertEqual(state["items"]["uid:whatsapp-ordered"]["folder_key"], "messengers")
|
||
self.assertEqual(state["folders"]["telegram"]["name"], "Telegram")
|
||
self.assertEqual(state["folders"]["whatsapp"]["name"], "WhatsApp")
|
||
|
||
def test_untouched_matrix_and_simplex_move_from_sites_to_messengers(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
for key in ("uid:matrix", "uid:simplex", "uid:rutor"):
|
||
state["items"][key] = {"folder_key": "sites", "order": None, "rating": 0}
|
||
# Этот Matrix пользователь сам переставил внутри «Сайтов».
|
||
state["items"]["uid:matrix-ordered"] = {"folder_key": "sites", "order": 0, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
self.assertTrue(
|
||
move_untouched_profiles_to_split_folders(
|
||
{
|
||
"uid:matrix": "messengers",
|
||
"uid:simplex": "messengers",
|
||
"uid:rutor": "sites",
|
||
"uid:matrix-ordered": "messengers",
|
||
}
|
||
)
|
||
)
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertEqual(state["items"]["uid:matrix"]["folder_key"], "messengers")
|
||
self.assertEqual(state["items"]["uid:simplex"]["folder_key"], "messengers")
|
||
self.assertEqual(state["items"]["uid:rutor"]["folder_key"], "sites")
|
||
self.assertEqual(state["items"]["uid:matrix-ordered"]["folder_key"], "sites")
|
||
|
||
def test_voice_profile_arranged_by_user_is_left_where_it_is(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
folder_key = create_profile_folder("Моя папка")
|
||
state = load_profile_folder_state()
|
||
# Пользователь раскладывал Discord сам: у профиля есть порядок.
|
||
state["items"]["uid:ordered"] = {"folder_key": "discord", "order": 2, "rating": 0}
|
||
# Пользователь унёс профиль в свою папку.
|
||
state["items"]["uid:moved"] = {"folder_key": folder_key, "order": None, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
self.assertFalse(
|
||
move_untouched_profiles_to_split_folders(
|
||
{"uid:ordered": "voice", "uid:moved": "voice", "uid:not-placed-yet": "voice"}
|
||
)
|
||
)
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertEqual(state["items"]["uid:ordered"]["folder_key"], "discord")
|
||
self.assertEqual(state["items"]["uid:moved"]["folder_key"], folder_key)
|
||
self.assertNotIn("uid:not-placed-yet", state["items"])
|
||
|
||
def test_profile_folder_reset_materializes_assignments(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
folder_key = create_profile_folder("Моя папка")
|
||
state = load_profile_folder_state()
|
||
state["items"]["uid:manual"] = {"folder_key": folder_key, "order": 3, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
state = reset_profile_folders({"uid:manual": "discord", "uid:unknown-folder": "no-such"})
|
||
|
||
self.assertNotIn(folder_key, state["folders"])
|
||
self.assertEqual(state["items"]["uid:manual"]["folder_key"], "discord")
|
||
self.assertIsNone(state["items"]["uid:manual"]["order"])
|
||
self.assertEqual(state["items"]["uid:unknown-folder"]["folder_key"], COMMON_FOLDER_KEY)
|
||
|
||
def test_save_profile_folder_state_skips_settings_write_when_state_is_unchanged(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
with patch(
|
||
"profile.folders.settings_store.set_folders_settings",
|
||
side_effect=AssertionError("unchanged profile folder state must not write settings"),
|
||
):
|
||
self.assertEqual(save_profile_folder_state(state), state)
|
||
|
||
def test_profile_folder_collapsed_skips_save_when_state_is_unchanged(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
self.assertTrue(set_profile_folder_collapsed("youtube", True))
|
||
with patch(
|
||
"profile.folders.save_profile_folder_state",
|
||
side_effect=AssertionError("unchanged folder collapsed state must not be saved"),
|
||
):
|
||
self.assertFalse(set_profile_folder_collapsed("youtube", True))
|
||
|
||
def test_profile_folders_collapsed_batch_saves_settings_once(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
calls = []
|
||
original_save = save_profile_folder_state
|
||
|
||
def wrapped_save(state):
|
||
calls.append(state)
|
||
return original_save(state)
|
||
|
||
with patch("profile.folders.save_profile_folder_state", side_effect=wrapped_save):
|
||
self.assertTrue(set_profile_folders_collapsed({"youtube": True, "discord": True}))
|
||
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertEqual(len(calls), 1)
|
||
self.assertTrue(state["folders"]["youtube"]["collapsed"])
|
||
self.assertTrue(state["folders"]["discord"]["collapsed"])
|
||
|
||
def test_profile_reorder_renumbers_folder_from_display_order(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
for index, key in enumerate(("a", "b", "c", "d")):
|
||
state["items"][key] = {"folder_key": COMMON_FOLDER_KEY, "order": index, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
planned = plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b", "c", "d"),
|
||
action="before",
|
||
source_key="d",
|
||
destination_key="a",
|
||
)
|
||
self.assertIsNotNone(planned)
|
||
save_profile_folder_state(planned)
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertEqual(state["items"]["d"]["order"], 0)
|
||
self.assertEqual(state["items"]["a"]["order"], 1)
|
||
self.assertEqual(state["items"]["b"]["order"], 2)
|
||
self.assertEqual(state["items"]["c"]["order"], 3)
|
||
|
||
def test_profile_move_after_matches_lower_drop_marker(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
for index, key in enumerate(("a", "b", "c")):
|
||
state["items"][key] = {"folder_key": COMMON_FOLDER_KEY, "order": index, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
planned = plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b", "c"),
|
||
action="after",
|
||
source_key="a",
|
||
destination_key="b",
|
||
)
|
||
self.assertIsNotNone(planned)
|
||
save_profile_folder_state(planned)
|
||
state = load_profile_folder_state()
|
||
|
||
self.assertEqual(state["items"]["b"]["order"], 0)
|
||
self.assertEqual(state["items"]["a"]["order"], 1)
|
||
self.assertEqual(state["items"]["c"]["order"], 2)
|
||
|
||
def test_duplicate_profile_move_after_skips_folder_state_save(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
for index, key in enumerate(("a", "b", "c")):
|
||
state["items"][key] = {"folder_key": COMMON_FOLDER_KEY, "order": index, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
first = plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b", "c"),
|
||
action="after",
|
||
source_key="a",
|
||
destination_key="b",
|
||
)
|
||
self.assertIsNotNone(first)
|
||
save_profile_folder_state(first)
|
||
# Повторное «a после b» уже не меняет отображаемый порядок — no-op.
|
||
self.assertIsNone(
|
||
plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b", "c"),
|
||
action="after",
|
||
source_key="a",
|
||
destination_key="b",
|
||
)
|
||
)
|
||
|
||
def test_duplicate_profile_move_to_end_skips_folder_state_save(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
for index, key in enumerate(("a", "b", "c")):
|
||
state["items"][key] = {"folder_key": COMMON_FOLDER_KEY, "order": index, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
first = plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b", "c"),
|
||
action="end",
|
||
source_key="a",
|
||
)
|
||
self.assertIsNotNone(first)
|
||
save_profile_folder_state(first)
|
||
# Профиль уже в конце папки — повторный move-to-end это no-op.
|
||
self.assertIsNone(
|
||
plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b", "c"),
|
||
action="end",
|
||
source_key="a",
|
||
)
|
||
)
|
||
|
||
def test_duplicate_profile_move_to_folder_skips_folder_state_save(self) -> None:
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(Path(temp_dir))):
|
||
state = load_profile_folder_state()
|
||
state["items"]["a"] = {"folder_key": "youtube", "order": 0, "rating": 0}
|
||
state["items"]["b"] = {"folder_key": "youtube", "order": 1, "rating": 0}
|
||
save_profile_folder_state(state)
|
||
|
||
# «b» уже последний в youtube — перенос в ту же папку это no-op.
|
||
self.assertIsNone(
|
||
plan_item_move(
|
||
load_profile_folder_state(),
|
||
_live_items("a", "b"),
|
||
action="folder",
|
||
source_key="b",
|
||
destination_folder_key="youtube",
|
||
)
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|