Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
@@ -43,24 +43,24 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
|
||||
msg("m2", role = "assistant", text = "hi", ts = 200),
|
||||
),
|
||||
"android:default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
||||
"default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadLanes()
|
||||
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys)
|
||||
assertEquals(setOf("default", "default::thr_1"), loaded.keys)
|
||||
// Tool cards are persisted and restored at their anchored position
|
||||
// (after the message they follow, before the answer).
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
|
||||
// Messages ordered by ts.
|
||||
assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts)
|
||||
assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts)
|
||||
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
|
||||
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +68,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
|
||||
@@ -77,7 +77,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,7 +85,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
|
||||
@@ -93,7 +93,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -101,7 +101,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
|
||||
@@ -109,7 +109,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["android:default"]!!
|
||||
val lane = db.loadLanes()["default"]!!
|
||||
// The process died before tool.end — the open card is closed as
|
||||
// interrupted, not left spinning.
|
||||
val open = lane.first { it.id == "tool_1" } as ToolItem
|
||||
@@ -123,9 +123,9 @@ class ChatDbTest {
|
||||
@Test
|
||||
fun saveLanesReplacesPreviousSnapshot() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -133,7 +133,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf(
|
||||
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
||||
msg("s1", role = "assistant", streaming = true, ts = 500),
|
||||
@@ -141,7 +141,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["android:default"]!!
|
||||
val lane = db.loadLanes()["default"]!!
|
||||
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
|
||||
// A pending send becomes failed (tap to retry); the gateway never
|
||||
// acknowledged it before the process died.
|
||||
@@ -156,7 +156,7 @@ class ChatDbTest {
|
||||
@Test
|
||||
fun systemMessagesAreNotPersisted() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
||||
assertTrue(db.loadLanes().isEmpty())
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(item)))
|
||||
val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
|
||||
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
|
||||
assertEquals("gpt", loaded.runtime?.model)
|
||||
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
||||
}
|
||||
@@ -193,26 +193,26 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveChannels(
|
||||
listOf(
|
||||
ChannelInfo(chatId = "android:default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "android:chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"),
|
||||
ChannelInfo(chatId = "default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadChannels()
|
||||
assertEquals(3, loaded.size)
|
||||
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name)
|
||||
assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
|
||||
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
||||
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault)
|
||||
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaRoundTripAndClearAll() {
|
||||
val db = newDb()
|
||||
assertNull(db.metaGet("last_lane"))
|
||||
db.metaPut("last_lane", "android:chan_1")
|
||||
assertEquals("android:chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General")))
|
||||
db.metaPut("last_lane", "chan_1")
|
||||
assertEquals("chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
|
||||
db.clearAll()
|
||||
assertTrue(db.loadLanes().isEmpty())
|
||||
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
|
||||
* `pairing.qr_payload` on the gateway side.
|
||||
*/
|
||||
class PairLinkTest {
|
||||
private val token = "ab".repeat(32) // 64 hex chars
|
||||
|
||||
@Test
|
||||
fun parsesValidLink() {
|
||||
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
|
||||
assertEquals("http://192.168.1.50:8791", link?.url)
|
||||
assertEquals(token, link?.token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsPortTo8791() {
|
||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
|
||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secureOneSelectsHttps() {
|
||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
|
||||
assertEquals("https://10.0.0.5:8443", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemeIsCaseInsensitive() {
|
||||
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
|
||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentDecodesHost() {
|
||||
// A hostname with a space would be %20 on the wire.
|
||||
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
|
||||
assertEquals("http://my host:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingToken() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEmptyToken() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingHost() {
|
||||
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBadPort() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongScheme() {
|
||||
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongHost() {
|
||||
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNoAuthority() {
|
||||
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsBoundaryPorts() {
|
||||
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
|
||||
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user