Skip to content

Commit 66202e6

Browse files
yaturnerclaude
andcommitted
ADFA-4484 Add comprehensive unit test suite across five modules
Add 93 new tests across 7 files covering git models, URL parsing, LLM tool-call parsing, chat transcript writing, debounce coalescing, diagnostics formatting, background indexing, and the index query DSL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c14733b commit 66202e6

9 files changed

Lines changed: 1460 additions & 3 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package com.itsaky.androidide.agent.repository
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNotNull
5+
import org.junit.Assert.assertNull
6+
import org.junit.Test
7+
import org.junit.runner.RunWith
8+
import org.junit.runners.JUnit4
9+
10+
@RunWith(JUnit4::class)
11+
class UtilParseToolCallTest {
12+
13+
private val allToolKeys = setOf(
14+
"list_files", "read_file", "search_project",
15+
"create_file", "update_file", "add_dependency", "get_current_datetime"
16+
)
17+
18+
// --- Happy path: clean JSON inside <tool_call> tags ---
19+
20+
@Test
21+
fun `parses tool call with name and empty args from tool_call tags`() {
22+
val input = """<tool_call>{"name": "get_current_datetime", "args": {}}</tool_call>"""
23+
val result = Util.parseToolCall(input, allToolKeys)
24+
assertNotNull(result)
25+
assertEquals("get_current_datetime", result!!.name)
26+
assertEquals(emptyMap<String, String>(), result.args)
27+
}
28+
29+
@Test
30+
fun `parses tool call with args from tool_call tags`() {
31+
val input = """<tool_call>{"name": "read_file", "args": {"file_path": "/src/main/Foo.kt"}}</tool_call>"""
32+
val result = Util.parseToolCall(input, allToolKeys)
33+
assertNotNull(result)
34+
assertEquals("read_file", result!!.name)
35+
assertEquals("/src/main/Foo.kt", result.args["file_path"])
36+
}
37+
38+
@Test
39+
fun `parses tool call for list_files with path arg`() {
40+
val input = """<tool_call>{"name": "list_files", "args": {"path": "/src"}}</tool_call>"""
41+
val result = Util.parseToolCall(input, allToolKeys)
42+
assertNotNull(result)
43+
assertEquals("list_files", result!!.name)
44+
assertEquals("/src", result.args["path"])
45+
}
46+
47+
@Test
48+
fun `parses tool call for search_project`() {
49+
val input = """<tool_call>{"name": "search_project", "args": {"query": "MainActivity"}}</tool_call>"""
50+
val result = Util.parseToolCall(input, allToolKeys)
51+
assertNotNull(result)
52+
assertEquals("search_project", result!!.name)
53+
assertEquals("MainActivity", result.args["query"])
54+
}
55+
56+
// --- Happy path: JSON without tool_call wrapper ---
57+
58+
@Test
59+
fun `parses bare JSON object with name field`() {
60+
val input = """{"name": "list_files", "args": {"path": "."}}"""
61+
val result = Util.parseToolCall(input, allToolKeys)
62+
assertNotNull(result)
63+
assertEquals("list_files", result!!.name)
64+
}
65+
66+
@Test
67+
fun `parses JSON wrapped in markdown code fence`() {
68+
val input = "```json\n{\"name\": \"read_file\", \"args\": {\"file_path\": \"/README.md\"}}\n```"
69+
val result = Util.parseToolCall(input, allToolKeys)
70+
assertNotNull(result)
71+
assertEquals("read_file", result!!.name)
72+
assertEquals("/README.md", result.args["file_path"])
73+
}
74+
75+
@Test
76+
fun `parses JSON wrapped in markdown code fence without json label`() {
77+
val input = "```\n{\"name\": \"list_files\", \"args\": {\"path\": \"/\"}}\n```"
78+
val result = Util.parseToolCall(input, allToolKeys)
79+
assertNotNull(result)
80+
assertEquals("list_files", result!!.name)
81+
}
82+
83+
// --- Name aliases ---
84+
85+
@Test
86+
fun `list_dir is resolved to list_files`() {
87+
val input = """<tool_call>{"name": "list_dir", "args": {"path": "/"}}</tool_call>"""
88+
val result = Util.parseToolCall(input, allToolKeys)
89+
assertNotNull(result)
90+
assertEquals("list_files", result!!.name)
91+
}
92+
93+
// --- tool_name alternative field ---
94+
95+
@Test
96+
fun `accepts tool_name field as alternative to name`() {
97+
val input = """{"tool_name": "list_files", "args": {"path": "."}}"""
98+
val result = Util.parseToolCall(input, allToolKeys)
99+
assertNotNull(result)
100+
assertEquals("list_files", result!!.name)
101+
}
102+
103+
// --- Tool-only tag (model emits just the name inside tags) ---
104+
105+
@Test
106+
fun `parses tool-only tag with no args`() {
107+
val input = "<tool_call>get_current_datetime</tool_call>"
108+
val result = Util.parseToolCall(input, allToolKeys)
109+
assertNotNull(result)
110+
assertEquals("get_current_datetime", result!!.name)
111+
}
112+
113+
// --- Failure cases: unknown tool ---
114+
115+
@Test
116+
fun `returns null when tool name is not in available tools`() {
117+
val input = """{"name": "unknown_tool", "args": {}}"""
118+
val result = Util.parseToolCall(input, allToolKeys)
119+
assertNull(result)
120+
}
121+
122+
@Test
123+
fun `returns null when name field is missing`() {
124+
val input = """{"args": {"path": "/"}}"""
125+
val result = Util.parseToolCall(input, allToolKeys)
126+
assertNull(result)
127+
}
128+
129+
@Test
130+
fun `returns null when name field is blank`() {
131+
val input = """{"name": "", "args": {}}"""
132+
val result = Util.parseToolCall(input, allToolKeys)
133+
assertNull(result)
134+
}
135+
136+
// --- Failure cases: empty or unrecognizable input ---
137+
138+
@Test
139+
fun `returns null for empty string`() {
140+
val result = Util.parseToolCall("", allToolKeys)
141+
assertNull(result)
142+
}
143+
144+
@Test
145+
fun `returns null for plain prose`() {
146+
val result = Util.parseToolCall("Sure! I will list the files for you.", allToolKeys)
147+
assertNull(result)
148+
}
149+
150+
@Test
151+
fun `returns null when no tools are provided and response has a tool call`() {
152+
val input = """{"name": "list_files", "args": {}}"""
153+
val result = Util.parseToolCall(input, emptySet())
154+
assertNull(result)
155+
}
156+
157+
// --- JSON with trailing prose (model hallucinates follow-on text) ---
158+
159+
@Test
160+
fun `extracts first JSON object and ignores trailing text`() {
161+
val input = """{"name": "read_file", "args": {"file_path": "/Foo.kt"}} Let me read that file now."""
162+
val result = Util.parseToolCall(input, allToolKeys)
163+
assertNotNull(result)
164+
assertEquals("read_file", result!!.name)
165+
}
166+
167+
// --- Args with multiple values ---
168+
169+
@Test
170+
fun `parses multiple args correctly`() {
171+
val input = """<tool_call>{"name": "create_file", "args": {"path": "/new.kt", "content": "class Foo {}"}}</tool_call>"""
172+
val result = Util.parseToolCall(input, allToolKeys)
173+
assertNotNull(result)
174+
assertEquals("create_file", result!!.name)
175+
assertEquals("/new.kt", result.args["path"])
176+
assertEquals("class Foo {}", result.args["content"])
177+
}
178+
179+
// --- Empty tool set ---
180+
181+
@Test
182+
fun `returns null when tool set is empty and input has JSON`() {
183+
val input = """{"name": "list_files", "args": {}}"""
184+
val result = Util.parseToolCall(input, emptySet())
185+
assertNull(result)
186+
}
187+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.itsaky.androidide.agent.utils
2+
3+
import android.content.Context
4+
import io.mockk.every
5+
import io.mockk.mockk
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Assert.fail
9+
import org.junit.Before
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.robolectric.RobolectricTestRunner
13+
import java.io.File
14+
import java.io.IOException
15+
import java.nio.file.Files
16+
17+
@RunWith(RobolectricTestRunner::class)
18+
class ChatTranscriptUtilsTest {
19+
20+
private lateinit var cacheDir: File
21+
private lateinit var context: Context
22+
23+
@Before
24+
fun setUp() {
25+
cacheDir = Files.createTempDirectory("transcript-test").toFile()
26+
context = mockk<Context> {
27+
every { this@mockk.cacheDir } returns cacheDir
28+
}
29+
}
30+
31+
// --- writeTranscriptToCache ---
32+
33+
@Test
34+
fun `writeTranscriptToCache creates file in chat_exports subdirectory`() {
35+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, "hello")
36+
assertTrue(file.exists())
37+
assertEquals("chat_exports", file.parentFile!!.name)
38+
}
39+
40+
@Test
41+
fun `writeTranscriptToCache writes content verbatim`() {
42+
val content = "User: hi\nAgent: hello!"
43+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, content)
44+
assertEquals(content, file.readText(Charsets.UTF_8))
45+
}
46+
47+
@Test
48+
fun `writeTranscriptToCache filename starts with chat-transcript`() {
49+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, "test")
50+
assertTrue(file.name.startsWith("chat-transcript-"))
51+
}
52+
53+
@Test
54+
fun `writeTranscriptToCache filename ends with txt`() {
55+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, "test")
56+
assertTrue(file.name.endsWith(".txt"))
57+
}
58+
59+
@Test
60+
fun `writeTranscriptToCache two calls produce distinct filenames`() {
61+
val f1 = ChatTranscriptUtils.writeTranscriptToCache(context, "a")
62+
Thread.sleep(5)
63+
val f2 = ChatTranscriptUtils.writeTranscriptToCache(context, "b")
64+
assertTrue("Expected distinct filenames but got: ${f1.name} and ${f2.name}", f1.name != f2.name)
65+
}
66+
67+
@Test
68+
fun `writeTranscriptToCache handles empty transcript`() {
69+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, "")
70+
assertTrue(file.exists())
71+
assertEquals("", file.readText())
72+
}
73+
74+
@Test
75+
fun `writeTranscriptToCache handles multiline unicode content`() {
76+
val content = "日本語\nكلام عربي\n中文"
77+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, content)
78+
assertEquals(content, file.readText(Charsets.UTF_8))
79+
}
80+
81+
@Test
82+
fun `writeTranscriptToCache throws when exports path is a file not a directory`() {
83+
val exportFile = File(cacheDir, "chat_exports")
84+
exportFile.createNewFile()
85+
86+
try {
87+
ChatTranscriptUtils.writeTranscriptToCache(context, "data")
88+
fail("Expected IOException")
89+
} catch (e: IOException) {
90+
assertTrue(
91+
"Message should mention 'not a directory', was: ${e.message}",
92+
e.message?.contains("not a directory") == true
93+
)
94+
}
95+
}
96+
97+
@Test
98+
fun `writeTranscriptToCache re-uses existing exports directory`() {
99+
val exportsDir = File(cacheDir, "chat_exports")
100+
exportsDir.mkdirs()
101+
102+
val file = ChatTranscriptUtils.writeTranscriptToCache(context, "second write")
103+
assertEquals(exportsDir.canonicalPath, file.parentFile!!.canonicalPath)
104+
}
105+
}

0 commit comments

Comments
 (0)