Агент: медиа-тулсет (search/info/play) на Jellyfin + скилл media
- MEDIA_TOOLSET + MediaToolExecutor: search (поиск с лимитом), info (детали), play (проверки+запуск) - JellyfinMediaSource: обёртка JellyfinClient (search/item) с защитой userId - agentPlayMedia: проверки (очки на связи, видео скачано на очки, звук на телефоне) → watch с позицией - PhoneActions.watch(startPositionMs), JellyfinItem.ProductionYear - скилл app-phone/skills/media.md (frontmatter: name/description) - MediaToolsTest: 22 кейса — всего 142 теста зелёные
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
|
||||
/**
|
||||
* Исполнитель тулсета media: валидация аргументов + форматирование текста
|
||||
* (search/info/play). Источник и игрок иньекшены фейками — JVM, без Android.
|
||||
*/
|
||||
class MediaToolsTest {
|
||||
|
||||
private class FakeSource : MediaSource {
|
||||
val items = mutableListOf(
|
||||
MediaItem("i1", "Lara Croft", "Movie", 2018, "Tomb Raider reboot", 8100L, true),
|
||||
MediaItem("i2", "The Witcher", "Series", 2019, null, 3300L, false),
|
||||
MediaItem("i3", "Stranger Things", "Series", 2016, " ", 3540L, true),
|
||||
MediaItem("i4", "Blade Runner", "Movie", 1982, "репликанты", 8340L, true),
|
||||
MediaItem("i5", "Dune", "Series", 2021, null, 4200L, false),
|
||||
)
|
||||
var searchFails = false
|
||||
var itemFails = false
|
||||
/** Фильтр поиска (по умолчанию все — тестируем лимит). */
|
||||
var match: (MediaItem) -> Boolean = { true }
|
||||
|
||||
override suspend fun search(query: String, limit: Int): List<MediaItem> {
|
||||
if (searchFails) throw MediaSourceException("нет пользователя Jellyfin (userId пуст)")
|
||||
return items.filter(match).take(limit)
|
||||
}
|
||||
|
||||
override suspend fun item(itemId: String): MediaItem? {
|
||||
if (itemFails) throw MediaSourceException("сеть недоступна")
|
||||
return items.firstOrNull { it.id == itemId }
|
||||
}
|
||||
}
|
||||
|
||||
private class FakePlayer(
|
||||
private val responses: Map<String, String> = emptyMap(),
|
||||
private var failure: Throwable? = null,
|
||||
) : MediaPlayer {
|
||||
val calls = mutableListOf<Pair<String, Long>>()
|
||||
|
||||
override suspend fun play(itemId: String, seekMs: Long): String {
|
||||
calls.add(itemId to seekMs)
|
||||
failure?.let { throw it }
|
||||
return responses[itemId] ?: "включил: $itemId"
|
||||
}
|
||||
}
|
||||
|
||||
private fun executor(source: FakeSource = FakeSource(), player: FakePlayer = FakePlayer()) =
|
||||
MediaToolExecutor(source, player)
|
||||
|
||||
// ---------- определение тулсета ----------
|
||||
|
||||
@Test
|
||||
fun `сигнатуры тулсета и префиксная маршрутизация`() {
|
||||
assertEquals(listOf("search", "info", "play"), MEDIA_TOOLSET.tools.map { it.name })
|
||||
assertEquals("media_search", MEDIA_TOOLSET.toolFull(MEDIA_TOOLSET.tools.first()))
|
||||
assertEquals(listOf("id"), MEDIA_TOOLSET.signatureOf("media_play")?.requiredArgs())
|
||||
assertEquals(listOf("query", "limit"), MEDIA_TOOLSET.signatureOf("media_search")?.argNames())
|
||||
}
|
||||
|
||||
// ---------- media_search ----------
|
||||
|
||||
@Test
|
||||
fun `search строки id название год тип`() {
|
||||
val source = FakeSource()
|
||||
source.match = { it.id == "i1" }
|
||||
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
|
||||
assertEquals("i1: Lara Croft (2018) — Movie", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `поиск без года не показывает скобок`() {
|
||||
val source = FakeSource()
|
||||
source.items[0] = source.items[0].copy(year = null)
|
||||
source.match = { it.id == "i1" }
|
||||
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
|
||||
assertEquals("i1: Lara Croft — Movie", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit по умолчанию 5, явного, кривой и ноль`() {
|
||||
val r = executor()
|
||||
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x")).lines().size)
|
||||
assertEquals(2, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "2")).lines().size)
|
||||
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "ab")).lines().size)
|
||||
// ноль и отрицательное — сжимаются в 1
|
||||
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "0")).lines().size)
|
||||
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "-4")).lines().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `поиск без query — прощающая схема`() {
|
||||
for (args in listOf(emptyMap<String, String>(), mapOf("query" to ""))) {
|
||||
val r = executor().execute("media", "media_search", args)
|
||||
assertTrue(r.contains("нет аргумента \"query\""), r)
|
||||
assertTrue(r.contains("media_search|<query>"), r)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `поиск пустая — ничего не найдено`() {
|
||||
val source = FakeSource()
|
||||
source.match = { false }
|
||||
val r = executor(source).execute("media", "media_search", mapOf("query" to "такого нет"))
|
||||
assertTrue(r.contains("Ничего не найдено"), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `поиск источник недоступен — сообщение с причиной`() {
|
||||
val source = FakeSource()
|
||||
source.searchFails = true
|
||||
val r = executor(source).execute("media", "media_search", mapOf("query" to "x"))
|
||||
assertTrue(r.contains("Поиск недоступен"), r)
|
||||
assertTrue(r.contains("userId пуст"), r)
|
||||
}
|
||||
|
||||
// ---------- media_info ----------
|
||||
|
||||
@Test
|
||||
fun `info полный набор полей`() {
|
||||
val r = executor().execute("media", "media_info", mapOf("id" to "i1"))
|
||||
assertTrue(r.contains("«Lara Croft» — Movie (2018)"), r)
|
||||
assertTrue(r.contains("Описание: Tomb Raider reboot"), r)
|
||||
assertTrue(r.contains("Длительность: 135 мин. (8100 сек.)"), r)
|
||||
assertTrue(r.contains("Постер: есть"), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `info без описания и постера — строки не выдумываются`() {
|
||||
val r = executor().execute("media", "media_info", mapOf("id" to "i2"))
|
||||
assertFalse(r.contains("Описание"), r)
|
||||
assertFalse(r.contains("8100"), r)
|
||||
assertTrue(r.contains("Постер: нет"), r)
|
||||
assertTrue(r.contains("55 мин. (3300 сек.)"), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `info описание длиннее 300 символов — обрезка с многоточием`() {
|
||||
val source = FakeSource()
|
||||
source.items[0] = source.items[0].copy(overview = "А".repeat(400))
|
||||
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
|
||||
assertTrue(r.contains("Описание: ${"А".repeat(300)}…"), r)
|
||||
assertFalse(r.contains("А".repeat(301)), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `info не найден и без аргумента`() {
|
||||
val r = executor().execute("media", "media_info", mapOf("id" to "iX"))
|
||||
assertTrue(r.contains("\"iX\" не найден"), r)
|
||||
val noArg = executor().execute("media", "media_info", emptyMap())
|
||||
assertTrue(noArg.contains("нет аргумента \"id\""), noArg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `info источник недоступен — сообщение с причиной`() {
|
||||
val source = FakeSource()
|
||||
source.itemFails = true
|
||||
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
|
||||
assertTrue(r.contains("Детали недоступны"), r)
|
||||
assertTrue(r.contains("сеть недоступна"), r)
|
||||
}
|
||||
|
||||
// ---------- media_play ----------
|
||||
|
||||
@Test
|
||||
fun `play передаёт id и seek_ms в игрок`() {
|
||||
val player = FakePlayer()
|
||||
val executor = executor(player = player)
|
||||
val r = executor.execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "3600000"))
|
||||
assertEquals("включил: i1", r)
|
||||
assertEquals(listOf("i1" to 3_600_000L), player.calls)
|
||||
// без seek — ноль
|
||||
assertEquals("включил: i2", executor.execute("media", "media_play", mapOf("id" to "i2")))
|
||||
assertEquals(2, player.calls.size)
|
||||
assertEquals(0L, player.calls.last().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play без id — схема аргументов`() {
|
||||
val r = executor().execute("media", "media_play", emptyMap())
|
||||
assertTrue(r.contains("нет аргумента \"id\""), r)
|
||||
assertTrue(r.contains("media_play|<id>"), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play кривой seek_ms — числовое требование`() {
|
||||
for (bad in listOf("час", "12.5")) {
|
||||
val r = executor().execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to bad))
|
||||
assertTrue(r.contains("\"seek_ms\" должен быть числом"), r)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play отрицательный seek — в ноль`() {
|
||||
val player = FakePlayer()
|
||||
executor(player = player).execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "-100"))
|
||||
assertEquals(0L, player.calls.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play игрок выбросил — обёртка ошибки`() {
|
||||
val player = FakePlayer(failure = RuntimeException("видео не найдено"))
|
||||
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
|
||||
assertTrue(r.contains("Ошибка воспроизведения"), r)
|
||||
assertTrue(r.contains("видео не найдено"), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play текст проверки игрока проходит как есть`() {
|
||||
val player = FakePlayer(mapOf("i1" to "видео не скачано на очки. Скачайте его."))
|
||||
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
|
||||
assertEquals("видео не скачано на очки. Скачайте его.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `неизвестный тул — список доступных`() {
|
||||
val r = executor().execute("media", "media_bogus", emptyMap())
|
||||
assertTrue(r.contains("Неизвестный тул"), r)
|
||||
assertTrue(r.contains("media_search, media_info, media_play"), r)
|
||||
}
|
||||
|
||||
// ---------- маппинг Jellyfin → MediaItem ----------
|
||||
|
||||
@Test
|
||||
fun `маппинг полей, постер, секунды из тиков`() {
|
||||
val item = JellyfinItem(
|
||||
Id = "abc",
|
||||
Name = " Blade ",
|
||||
Type = "Movie",
|
||||
ProductionYear = 2259,
|
||||
Overview = "текст",
|
||||
RunTimeTicks = 33_000_000_000L, // 3300 сек
|
||||
ImageTags = mapOf("Primary" to "hash"),
|
||||
)
|
||||
assertEquals(
|
||||
MediaItem("abc", "Blade", "Movie", 2259, "текст", 3300L, true),
|
||||
jellyfinToMediaItem(item),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `маппинг пустое имя в id, без постера и секунд`() {
|
||||
val m = jellyfinToMediaItem(JellyfinItem(Id = "x1", Name = " ", Type = "Episode"))
|
||||
assertEquals("x1", m.name)
|
||||
assertFalse(m.hasPoster)
|
||||
assertEquals(null, m.year)
|
||||
assertEquals(null, m.durationSeconds)
|
||||
}
|
||||
|
||||
// ---------- JellyfinMediaSource: защита userId ----------
|
||||
|
||||
@Test
|
||||
fun `источник без пользователя — исключение у источника`() {
|
||||
val source = JellyfinMediaSource(JellyfinClient("http://jellyfin.test", "key")) { " " }
|
||||
runBlocking {
|
||||
assertFailsWith<MediaSourceException> { source.search("x", 5) }
|
||||
assertFailsWith<MediaSourceException> { source.item("i1") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user