feat: implement photo albums (media groups) and persist account selection

Group photos with shared media_album_id into single album bubbles with
grid layout (up to 3x cols). Album navigation treats grouped photos as
one unit (j/k skip entire album). Persist selected account to
accounts.toml so it survives app restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-22 16:18:04 +03:00
parent 8bd08318bb
commit 78fe09bf11
18 changed files with 1011 additions and 30 deletions

View File

@@ -288,6 +288,137 @@ async fn test_normal_mode_auto_enters_message_selection() {
assert!(app.is_selecting_message());
}
/// Test: j/k перескакивают через альбом как одно сообщение
#[tokio::test]
async fn test_album_navigation_skips_grouped_messages() {
let messages = vec![
TestMessageBuilder::new("Before album", 1).sender("Alice").build(),
TestMessageBuilder::new("Photo 1", 2)
.sender("Alice")
.media_album_id(100)
.build(),
TestMessageBuilder::new("Photo 2", 3)
.sender("Alice")
.media_album_id(100)
.build(),
TestMessageBuilder::new("Photo 3", 4)
.sender("Alice")
.media_album_id(100)
.build(),
TestMessageBuilder::new("After album", 5).sender("Alice").build(),
];
let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101)
.with_messages(101, messages)
.build();
// Входим в режим выбора — начинаем с последнего (index=4, "After album")
app.start_message_selection();
assert!(app.is_selecting_message());
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "After album");
// k (up) — перескакиваем альбом, попадаем на первый элемент альбома (index=1)
app.select_previous_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "Photo 1");
assert_eq!(msg.media_album_id(), 100);
// k (up) — перескакиваем на сообщение до альбома (index=0)
app.select_previous_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "Before album");
// j (down) — перескакиваем на первый элемент альбома (index=1)
app.select_next_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "Photo 1");
// j (down) — перескакиваем альбом, попадаем на "After album" (index=4)
app.select_next_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "After album");
}
/// Test: Начало выбора, когда последнее сообщение — часть альбома
#[tokio::test]
async fn test_album_navigation_start_at_album_end() {
let messages = vec![
TestMessageBuilder::new("Regular", 1).sender("Alice").build(),
TestMessageBuilder::new("Album Photo 1", 2)
.sender("Alice")
.media_album_id(200)
.build(),
TestMessageBuilder::new("Album Photo 2", 3)
.sender("Alice")
.media_album_id(200)
.build(),
];
let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101)
.with_messages(101, messages)
.build();
// Входим в режим выбора — должны оказаться на первом элементе альбома (index=1)
app.start_message_selection();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "Album Photo 1");
// k (up) — на обычное сообщение
app.select_previous_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "Regular");
}
/// Test: Два альбома подряд — навигация между ними
#[tokio::test]
async fn test_album_navigation_two_albums() {
let messages = vec![
TestMessageBuilder::new("A1-P1", 1)
.sender("Alice")
.media_album_id(100)
.build(),
TestMessageBuilder::new("A1-P2", 2)
.sender("Alice")
.media_album_id(100)
.build(),
TestMessageBuilder::new("A2-P1", 3)
.sender("Alice")
.media_album_id(200)
.build(),
TestMessageBuilder::new("A2-P2", 4)
.sender("Alice")
.media_album_id(200)
.build(),
];
let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101)
.with_messages(101, messages)
.build();
// Начинаем — последний альбом (index=2, первый элемент album 200)
app.start_message_selection();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "A2-P1");
// k — перескакиваем на первый альбом (index=0)
app.select_previous_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "A1-P1");
// j — перескакиваем на второй альбом (index=2)
app.select_next_message();
let msg = app.get_selected_message().unwrap();
assert_eq!(msg.text(), "A2-P1");
}
/// Test: Циклическая навигация по списку чатов (переход с конца в начало)
#[tokio::test]
async fn test_circular_navigation_optional() {