Агент: механика скиллов и тулсетов (ядро 5 тул, подмена, невидимые enable/disable)
По ТЗ TASK-agent-tools.md: - Skills/SkillRepository/SkillRegistry: frontmatter-скиллы из files/skills, индекс для промпта, ленивое read_skill - Toolsets/ToolsetRegistry: активность на сессию в хиппе, таймаут 10 мин (инжект часов), префиксная маршрутизация, идемпотентные enable/disable - AgentToolkit: сборка промпта (3 состояния), диспач, бесшовная подмена вызова на активацию, валидация аргументов - Assistant: цикл тулов с невидимыми вызовами (MAX_LLM_CALLS=8, MAX_TOOL_CALLS=3) - Тесты: 18 новых (AgentToolkitTest 17 + SkillsTest 6 + ToolsetRegistryTest 9) — всего 120, зелёные
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Механика агента: три состояния промпта, бесшовная подмена, валидация
|
||||
* аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность.
|
||||
*/
|
||||
class AgentToolkitTest {
|
||||
|
||||
private var now = 10_000_000L
|
||||
|
||||
private class Fixture(val toolkit: AgentToolkit, val calls: MutableList<String>, val shown: MutableList<String>)
|
||||
|
||||
private fun fixture(withExecutor: Boolean = true): Fixture {
|
||||
now = 10_000_000L
|
||||
val calls = mutableListOf<String>()
|
||||
val shown = mutableListOf<String>()
|
||||
val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now })
|
||||
val ts = Toolset(
|
||||
name = "media",
|
||||
description = "Управление медиа и воспроизведением (search/info/play)",
|
||||
tools = listOf(
|
||||
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
|
||||
ToolSignature("info", "Детали фильма.", args = listOf("id")),
|
||||
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
|
||||
),
|
||||
)
|
||||
if (withExecutor) {
|
||||
registry.register(ts) { tset, tool, args ->
|
||||
calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}")
|
||||
"Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}"
|
||||
}
|
||||
} else {
|
||||
registry.register(ts, null)
|
||||
}
|
||||
val skills = SkillRegistry(
|
||||
listOf(
|
||||
Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."),
|
||||
Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."),
|
||||
),
|
||||
)
|
||||
return Fixture(
|
||||
toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }),
|
||||
calls = calls,
|
||||
shown = shown,
|
||||
)
|
||||
}
|
||||
|
||||
private fun run(d: AgentToolkit.Dispatch): String =
|
||||
(d as AgentToolkit.Dispatch.Run).result
|
||||
|
||||
// ---------- три состояния промпта ----------
|
||||
|
||||
@Test
|
||||
fun `состояние 1 нет активных тулсетов, каталог и скиллы`() {
|
||||
val p = fixture().toolkit.systemPrompt(1)
|
||||
assertTrue(p.contains("Ядро:"))
|
||||
assertTrue(p.contains("get_current_time"))
|
||||
assertTrue(p.contains("show_text|text"))
|
||||
assertTrue(p.contains("enable_toolset|имя"))
|
||||
assertTrue(p.contains("Каталог тулсетов"))
|
||||
assertTrue(p.contains("media — Управление медиа и воспроизведением"))
|
||||
// скиллы: по строке на каждый
|
||||
assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление."))
|
||||
assertTrue(p.contains("web — Как сделать веб-поиск."))
|
||||
// описания тул набора ещё не раскрыты (набор не активен)
|
||||
assertFalse(p.contains("Активные тулсеты"))
|
||||
assertFalse(p.contains("media_search|query"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enable невидимый, промпт пересобирается, состояние 3`() {
|
||||
val f = fixture()
|
||||
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(d is AgentToolkit.Dispatch.Silent)
|
||||
val p = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p.contains("Активные тулсеты: media"))
|
||||
assertTrue(p.contains("media_search|query — Поиск фильмов"))
|
||||
assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление"))
|
||||
// связанный скилл убран из промпта (имя скилла = имя тулсета)
|
||||
assertFalse(p.contains("media — Поиск фильмов"))
|
||||
// остальные скиллы на месте
|
||||
assertTrue(p.contains("web — Как сделать веб-поиск."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `повторный enable, напоминание, уже активен`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
|
||||
val p1 = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён"))
|
||||
// напоминание одноразовое: следующий промпт без него
|
||||
val p2 = f.toolkit.systemPrompt(1)
|
||||
assertFalse(p2.contains("уже активен"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disable обратно в состояние 1, двойной disable не ошибка`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
|
||||
val p1 = f.toolkit.systemPrompt(1)
|
||||
assertFalse(p1.contains("Активные тулсеты"))
|
||||
assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс
|
||||
val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
|
||||
val p2 = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p2.contains("Нет активных тулсетов"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `неизвестный набор, напоминание с доступными в промпте`() {
|
||||
val f = fixture()
|
||||
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other"))
|
||||
assertTrue(d is AgentToolkit.Dispatch.Silent)
|
||||
val p = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p.contains("Набора \"other\" нет"))
|
||||
assertTrue(p.contains("media"))
|
||||
}
|
||||
|
||||
// ---------- бесшовная подмена ----------
|
||||
|
||||
@Test
|
||||
fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
|
||||
assertTrue(msg.contains("Тулсет media активирован"))
|
||||
assertTrue(msg.contains("Реестр:"))
|
||||
assertTrue(msg.contains("media_search"))
|
||||
assertTrue(msg.contains("ещё НЕ выполнялся"))
|
||||
assertTrue(msg.contains("вызови media_search с аргументами: query"))
|
||||
// тул не выполнялся реально
|
||||
assertTrue(f.calls.isEmpty())
|
||||
// набор стал активен (подмена = enable)
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `подмена, валидные аргументы, выполнение без второго круга`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел")))
|
||||
assertTrue(msg.contains("Результат media_search"))
|
||||
assertEquals(1, f.calls.size)
|
||||
assertTrue(f.calls.single().contains("media.media_search"))
|
||||
assertTrue(f.calls.single().contains("query=Рассел"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
}
|
||||
|
||||
// ---------- валидация и маршрутизация ----------
|
||||
|
||||
@Test
|
||||
fun `активный тулсет, не хватает аргументов, ошибка со схемой`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
|
||||
assertTrue(msg.contains("аргументы неверны"))
|
||||
assertTrue(msg.contains("query"))
|
||||
assertTrue(msg.contains("Схема: media_search|query"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `маршрутизация по префкису в набор, вызов идёт в executor`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123")))
|
||||
assertTrue(msg.contains("Результат media_info"))
|
||||
assertTrue(f.calls.single().contains("media.media_info"))
|
||||
assertTrue(f.calls.single().contains("id=123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `тул не входит в сигнатуры набора`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap()))
|
||||
assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `неизвестный тул, ответ со списком доступных`() {
|
||||
val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap()))
|
||||
assertTrue(msg.contains("Тул \"frobo\" не найден"))
|
||||
assertTrue(msg.contains("enable_toolset"))
|
||||
assertTrue(msg.contains("media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `активный тулсет без исполнителя, понятное сообщение`() {
|
||||
val f = fixture(withExecutor = false)
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x")))
|
||||
assertTrue(msg.contains("ещё не реализованы"))
|
||||
}
|
||||
|
||||
// ---------- ядро тулов ----------
|
||||
|
||||
@Test
|
||||
fun `read_skill, весь текст скилла`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media")))
|
||||
assertTrue(msg.contains("ТЕЛО: media_search|query=..."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read_skill неизвестный скилл, прощающая подсказка`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого")))
|
||||
assertTrue(msg.contains("Скилл \"неттакого\" не найден"))
|
||||
assertTrue(msg.contains("media"))
|
||||
assertTrue(msg.contains("web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!")))
|
||||
assertEquals("Привет!", f.shown.single())
|
||||
assertTrue(msg.contains("Показано на очках: Привет!"))
|
||||
val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap()))
|
||||
assertTrue(bad.contains("\"text\""))
|
||||
// без подключённого дисплея (showText = null) — понятный результат, не падение
|
||||
val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now }))
|
||||
val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X")))
|
||||
assertTrue(disconnected.contains("не показан"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_current_time, время в ISO`() {
|
||||
val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap()))
|
||||
val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")
|
||||
assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg")
|
||||
}
|
||||
|
||||
// ---------- таймаут и сессии ----------
|
||||
|
||||
@Test
|
||||
fun `истёкший тулсет исчезает из промпта, скилл возвращается`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
// инжект часов: проскакиваем таймаут без реального ожидания 10 минут
|
||||
now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L
|
||||
val p = f.toolkit.systemPrompt(1)
|
||||
assertFalse(p.contains("Активные тулсеты"))
|
||||
assertFalse(p.contains("media_search|query"))
|
||||
assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `активность по сессиям, разные сессии независимы`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
|
||||
// в сессии 2 вызов тула неактивного набора — подмена активирует только её
|
||||
val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X")))
|
||||
assertTrue(msg.contains("Результат media_search"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disable в сессии 2 не трогает активный в сессии 1`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media"))
|
||||
f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
|
||||
}
|
||||
|
||||
// ---------- парсинг аргументов ----------
|
||||
|
||||
@Test
|
||||
fun `парсинг пар и голых значений, прощающий к лишнему`() {
|
||||
val tk = fixture().toolkit
|
||||
assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name")))
|
||||
assertEquals(emptyMap<String, String>(), tk.parseArgs(null, listOf("name")))
|
||||
assertEquals(emptyMap<String, String>(), tk.parseArgs(" ", listOf("name")))
|
||||
assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query")))
|
||||
assertEquals(
|
||||
mapOf("id" to "12", "seek_ms" to "300"),
|
||||
tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")),
|
||||
)
|
||||
// «голое» значение — первому обязательному аргументу
|
||||
assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?")))
|
||||
// пар через «&» и лишние ключи — не ошибка
|
||||
val m = tk.parseArgs("query=x&extra=5", listOf("query"))
|
||||
assertEquals("x", m["query"])
|
||||
assertTrue(m.containsKey("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `схема аргументов, schemaFor, у тулов`() {
|
||||
val tk = fixture().toolkit
|
||||
assertEquals(emptyList(), tk.schemaFor("get_current_time"))
|
||||
assertEquals(listOf("text"), tk.schemaFor("show_text"))
|
||||
assertEquals(listOf("name"), tk.schemaFor("enable_toolset"))
|
||||
assertEquals(listOf("name"), tk.schemaFor("read_skill"))
|
||||
assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play"))
|
||||
assertEquals(emptyList(), tk.schemaFor("unknown_tool"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import java.io.File
|
||||
|
||||
// Скиллы: реестр (JVM, без Android) — файлы skills/*.md в приложении.
|
||||
class SkillsTest {
|
||||
|
||||
private fun freshDir(): File =
|
||||
File.createTempFile("vw-skills", "").also { it.delete(); it.mkdirs() }
|
||||
|
||||
private fun write(dir: File, fileName: String, content: String) {
|
||||
File(dir, fileName).writeText(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `файл с frontmatter парсится в скилл`() {
|
||||
val dir = freshDir()
|
||||
write(dir, "media.md", """
|
||||
---
|
||||
name: media
|
||||
---
|
||||
Вызови enable_toolset|media и дождись реестра тул.
|
||||
1. media_search|query=<название>
|
||||
""".trimIndent())
|
||||
val skills = SkillRepository(dir).loadAll()
|
||||
assertEquals(1, skills.size)
|
||||
val s = skills.single()
|
||||
assertEquals("media", s.name)
|
||||
assertTrue(s.body.contains("enable_toolset|media"))
|
||||
// frontmatter (заголовок) не должен попасть в тело
|
||||
assertFalse(s.body.startsWith("---"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `description в frontmatter становится подсказкой (триггером)`() {
|
||||
val dir = freshDir()
|
||||
write(dir, "web.md", """
|
||||
---
|
||||
name: web
|
||||
description: Как сделать веб-поиск.
|
||||
---
|
||||
Шаги поиска...
|
||||
""".trimIndent())
|
||||
val s = SkillRepository(dir).loadAll().single()
|
||||
assertEquals("web", s.name)
|
||||
assertEquals("Как сделать веб-поиск.", s.trigger)
|
||||
assertTrue(s.body.contains("Шаги поиска"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `файл без frontmatter берёт имя из имени файла`() {
|
||||
val dir = freshDir()
|
||||
write(dir, "tips.md", "Советы: запускать воспроизведение сразу после буферизации.")
|
||||
val s = SkillRepository(dir).loadAll().single()
|
||||
assertEquals("tips", s.name)
|
||||
assertEquals("Советы: запускать воспроизведение сразу после буферизации.", s.body)
|
||||
assertEquals("", s.trigger)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `пустой каталог и несуществующий каталог — пустой список`() {
|
||||
val empty = freshDir()
|
||||
assertTrue(SkillRepository(empty).loadAll().isEmpty())
|
||||
assertTrue(SkillRepository(File("no-such-skills-dir")).loadAll().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `реестр строки индекса и имена`() {
|
||||
val reg = SkillRegistry(
|
||||
listOf(
|
||||
Skill("media", "Поиск фильмов, детали, запуск/управление.", "тело1"),
|
||||
Skill("web", "Как сделать веб-поиск.", "тело2"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("media", "web"), reg.names())
|
||||
val lines = reg.index()
|
||||
assertEquals(2, lines.size)
|
||||
assertTrue(lines.any { it.startsWith("media — ") })
|
||||
assertTrue(lines.any { it.startsWith("web — ") })
|
||||
assertTrue(reg.index(excluded = setOf("media")).all { !it.startsWith("media") })
|
||||
assertTrue(reg.index(excluded = emptySet()) == lines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readFull полный текст, неизвестный скилл — null и has`() {
|
||||
val reg = SkillRegistry(listOf(Skill("media", "триггер", "текст_тела")))
|
||||
val full = reg.readFull("media")
|
||||
assertNotNull(full)
|
||||
assertTrue(full.contains("триггер"))
|
||||
assertTrue(full.contains("текст_тела"))
|
||||
assertNull(reg.readFull("other"))
|
||||
assertTrue(reg.has("media"))
|
||||
assertFalse(reg.has("other"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Реестр тулсетов: активность на сессию, таймаут (инжект часов — без ожидания
|
||||
* реальных 10 минут), идемпотентность enable/disable, префиксная маршрутизация.
|
||||
*/
|
||||
class ToolsetRegistryTest {
|
||||
|
||||
private var now = 1_000_000L
|
||||
|
||||
private fun newRegistry(timeoutMs: Long = ToolsetRegistry.DEFAULT_TIMEOUT_MS): ToolsetRegistry {
|
||||
now = 1_000_000L
|
||||
return ToolsetRegistry(timeoutMs = timeoutMs, clock = { now })
|
||||
}
|
||||
|
||||
private fun media(): Toolset = Toolset(
|
||||
name = "media",
|
||||
description = "Управление медиа и воспроизведением (search/info/play)",
|
||||
tools = listOf(
|
||||
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
|
||||
ToolSignature("info", "Детали фильма.", args = listOf("id")),
|
||||
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `enable активирует набор, активность на сессию`() {
|
||||
val r = newRegistry()
|
||||
r.register(media())
|
||||
val result = r.enable(1, "media")
|
||||
assertTrue(result is EnableResult.Activated)
|
||||
assertEquals(listOf("media"), r.activeNames(1))
|
||||
assertTrue(r.isActive(1, "media"))
|
||||
// другая сессия — не активна: активность НЕ персистится и НЕ общее
|
||||
assertFalse(r.isActive(2, "media"))
|
||||
assertEquals(emptyList(), r.activeNames(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `повторный enable — продление, не ошибка`() {
|
||||
val r = newRegistry(timeoutMs = 600_000)
|
||||
r.register(media())
|
||||
r.enable(1, "media")
|
||||
now += 9 * 60_000
|
||||
val result = r.enable(1, "media") as EnableResult.Activated
|
||||
assertTrue(result.alreadyActive)
|
||||
now += 9 * 60_000 // 18 минут суммарно, но от последнего вызова — 9
|
||||
assertTrue(r.isActive(1, "media"))
|
||||
now += 600_001L
|
||||
assertFalse(r.isActive(1, "media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `истёкший таймаут чистится лениво`() {
|
||||
val r = newRegistry(timeoutMs = 600_000)
|
||||
r.register(media())
|
||||
r.enable(1, "media")
|
||||
now += 600_001L
|
||||
assertEquals(emptyList(), r.activeNames(1))
|
||||
assertFalse(r.isActive(1, "media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disable идемпотентен, не ошибка`() {
|
||||
val r = newRegistry()
|
||||
r.register(media())
|
||||
val first = r.disable(1, "media") as DisableResult.NotActive
|
||||
assertTrue(first.message.contains("Нет активных тулсетов"))
|
||||
r.enable(1, "media")
|
||||
assertTrue(r.disable(1, "media") is DisableResult.Disabled)
|
||||
val again = r.disable(1, "media") as DisableResult.NotActive
|
||||
assertTrue(again.message.contains("Нет активных тулсетов"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disable другого набора, когда активен свой — сообщение без «нет активных»`() {
|
||||
val r = newRegistry()
|
||||
r.register(media())
|
||||
r.register(Toolset("web", "Работа с вебом.", emptyList()))
|
||||
r.enable(1, "web")
|
||||
val res = r.disable(1, "media") as DisableResult.NotActive
|
||||
assertTrue(res.message.contains("\"media\" не активен"))
|
||||
// активный «web» затронут не был
|
||||
assertTrue(r.isActive(1, "web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `неизвестный набор — сообщение с доступными в ответе`() {
|
||||
val r = newRegistry()
|
||||
r.register(media())
|
||||
val unknown = r.enable(1, "other") as EnableResult.Unknown
|
||||
assertEquals("other", unknown.name)
|
||||
assertTrue(unknown.message.contains("\"other\""))
|
||||
assertTrue(unknown.message.contains("media"))
|
||||
|
||||
val emptyReg = newRegistry()
|
||||
val empty = emptyReg.enable(1, "other") as EnableResult.Unknown
|
||||
assertTrue(empty.message.contains("Доступных тулсетов нет"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `маршрутизация по префкису и сигнатура по полному имени`() {
|
||||
val r = newRegistry()
|
||||
r.register(media())
|
||||
assertEquals("media", r.findByPrefix("media_search")?.name)
|
||||
assertEquals("media", r.findByPrefix("media_play")?.name)
|
||||
// без подчёркивания это имя набора, не тул
|
||||
assertEquals(null, r.findByPrefix("media"))
|
||||
assertEquals(null, r.findByPrefix("web_search"))
|
||||
assertEquals("search", r.signatureOf("media_search")?.name)
|
||||
assertEquals(listOf("id", "seek_ms?"), r.signatureOf("media_play")?.args)
|
||||
assertEquals(null, r.signatureOf("media_bogus"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `touch продлевает таймаут на каждом вызове тулы набора`() {
|
||||
val r = newRegistry(timeoutMs = 600_000)
|
||||
r.register(media())
|
||||
r.enable(1, "media")
|
||||
now += 9 * 60_000
|
||||
r.touch(1, "media")
|
||||
now += 9 * 60_000 // от последнего touch — снова 9 минут
|
||||
assertTrue(r.isActive(1, "media"))
|
||||
now += 600_001L
|
||||
assertFalse(r.isActive(1, "media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `каталог и активные — независимы, строки каталога по одной на набор`() {
|
||||
val r = newRegistry()
|
||||
r.register(media())
|
||||
r.register(Toolset("web", "Работа с вебом.", emptyList()))
|
||||
val catalog = r.catalogLines()
|
||||
assertEquals(2, catalog.size)
|
||||
assertTrue(catalog.any { it.startsWith("media — ") })
|
||||
assertTrue(catalog.any { it.startsWith("web — ") })
|
||||
// каталог есть, активных нет
|
||||
assertEquals(emptyList(), r.activeNames(7))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user