package cc.unitmesh.xuiper.dsl import cc.unitmesh.xuiper.action.HttpMethod import cc.unitmesh.xuiper.action.NanoAction import cc.unitmesh.xuiper.ast.NanoNode import cc.unitmesh.xuiper.ir.NanoIRConverter import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class NanoDSLParserTest { private fun loadResourceText(resourcePath: String): String { val stream = javaClass.classLoader.getResourceAsStream(resourcePath) ?: error("Test resource not found: $resourcePath") return stream.bufferedReader().use { it.readText() } } @Test fun `should parse simple component`() { val source = """ component GreetingCard: Card: Text("Hello!", style="h2") """.trimIndent() val result = NanoDSL.parse(source) assertEquals("GreetingCard", result.name) assertEquals(1, result.children.size) assertTrue(result.children[0] is NanoNode.Card) } @Test fun `should parse VStack with spacing`() { val source = """ component TestComponent: VStack(spacing="md"): Text("First") Text("Second") """.trimIndent() val result = NanoDSL.parse(source) assertEquals("TestComponent", result.name) val vstack = result.children[0] as NanoNode.VStack assertEquals("md", vstack.spacing) assertEquals(2, vstack.children.size) } @Test fun `should parse HStack with alignment`() { val source = """ component TestComponent: HStack(align="center", justify="between"): Text("Left") Text("Right") """.trimIndent() val result = NanoDSL.parse(source) val hstack = result.children[0] as NanoNode.HStack assertEquals("center", hstack.align) assertEquals("between", hstack.justify) } @Test fun `should parse Card with nested content`() { val source = """ component ProductCard: Card: padding: "md" VStack: Text("Product Name", style="h3") Text("$99.99") """.trimIndent() val result = NanoDSL.parse(source) val card = result.children[0] as NanoNode.Card assertEquals("md", card.padding) assertEquals(1, card.children.size) assertTrue(card.children[0] is NanoNode.VStack) } @Test fun `should parse Button with properties`() { val source = """ component TestComponent: Button("Click me", intent="primary") """.trimIndent() val result = NanoDSL.parse(source) val button = result.children[0] as NanoNode.Button assertEquals("Click me", button.label) assertEquals("primary", button.intent) } @Test fun `should parse conditional rendering`() { val source = """ component TestComponent: if item.is_new: Badge("New", color="green") """.trimIndent() val result = NanoDSL.parse(source) val conditional = result.children[0] as NanoNode.Conditional assertEquals("item.is_new", conditional.condition) assertEquals(1, conditional.thenBranch.size) assertTrue(conditional.thenBranch[0] is NanoNode.Badge) } @Test fun `should parse for loop`() { val source = """ component TaskList: for task in state.tasks: Text("Task item") """.trimIndent() val result = NanoDSL.parse(source) val forLoop = result.children[0] as NanoNode.ForLoop assertEquals("task", forLoop.variable) assertEquals("state.tasks", forLoop.iterable) assertEquals(1, forLoop.body.size) } @Test fun `should keep multiline JSON state defaults and emit JsonArray in IR`() { val source = loadResourceText("nanodsl/travelplan.dsl") val ir = NanoDSL.toIR(source) val flightsDefault = ir.state!!.variables["flights"]!!.defaultValue assertTrue(flightsDefault is JsonArray) assertEquals(2, flightsDefault.size) val first = flightsDefault[0].jsonObject assertEquals("CA1234", first["flightNumber"]!!.jsonPrimitive.content) assertEquals("Air China", first["airline"]!!.jsonPrimitive.content) } @Test fun `should convert to JSON IR`() { val source = """ component GreetingCard: Card: Text("Hello!", style="h2") """.trimIndent() val ir = NanoDSL.toIR(source) assertEquals("Component", ir.type) assertNotNull(ir.children) assertEquals(1, ir.children!!.size) assertEquals("Card", ir.children!![0].type) } @Test fun `should convert to JSON string`() { val source = """ component SimpleCard: Text("Hello") """.trimIndent() val json = NanoDSL.toJson(source) assertTrue(json.contains("\"type\": \"Component\"")) assertTrue(json.contains("\"type\": \"Text\"")) } @Test fun `should parse Button with Fetch action`() { val source = """ component LoginForm: Button("Submit", intent="primary"): on_click: Fetch(url="/api/login", method="POST") """.trimIndent() val result = NanoDSL.parse(source) val button = result.children[0] as NanoNode.Button assertEquals("Submit", button.label) assertNotNull(button.onClick) assertTrue(button.onClick is NanoAction.Fetch) val fetch = button.onClick as NanoAction.Fetch assertEquals("/api/login", fetch.url) assertEquals(HttpMethod.POST, fetch.method) } @Test fun `should parse Fetch with body parameters`() { val source = """ component LoginForm: Button("Submit"): on_click: Fetch(url="/api/login", method="POST", body={"email": state.email, "password": state.password}) """.trimIndent() val result = NanoDSL.parse(source) val button = result.children[0] as NanoNode.Button val fetch = button.onClick as NanoAction.Fetch assertEquals("/api/login", fetch.url) assertEquals(HttpMethod.POST, fetch.method) assertNotNull(fetch.body) assertEquals(2, fetch.body!!.size) assertTrue(fetch.body!!.containsKey("email")) assertTrue(fetch.body!!.containsKey("password")) } @Test fun `should parse Fetch with headers`() { val source = """ component ApiForm: Button("Call API"): on_click: Fetch(url="/api/data", method="GET", headers={"Authorization": "Bearer token123"}) """.trimIndent() val result = NanoDSL.parse(source) val button = result.children[0] as NanoNode.Button val fetch = button.onClick as NanoAction.Fetch assertEquals("/api/data", fetch.url) assertNotNull(fetch.headers) assertEquals("Bearer token123", fetch.headers!!["Authorization"]) } @Test fun `should parse TextArea component`() { val source = """ component MessageForm: TextArea(value := state.message, placeholder="Enter message", rows=4) """.trimIndent() val result = NanoDSL.parse(source) val textArea = result.children[0] as NanoNode.TextArea assertNotNull(textArea.value) assertEquals("Enter message", textArea.placeholder) assertEquals(4, textArea.rows) } @Test fun `should parse Select component`() { val source = """ component CountryForm: Select(value := state.country, options=countries, placeholder="Select country") """.trimIndent() val result = NanoDSL.parse(source) val select = result.children[0] as NanoNode.Select assertNotNull(select.value) assertEquals("countries", select.options) assertEquals("Select country", select.placeholder) } @Test fun `should parse Form component with children`() { val source = """ component ContactForm: Form(onSubmit=request.submit): Input(value := state.email, placeholder="Email") Button("Submit", intent="primary") """.trimIndent() val result = NanoDSL.parse(source) val form = result.children[0] as NanoNode.Form assertEquals("request.submit", form.onSubmit) assertEquals(2, form.children.size) assertTrue(form.children[0] is NanoNode.Input) assertTrue(form.children[1] is NanoNode.Button) } @Test fun `should parse multi-line on_click with state mutation and Fetch`() { val source = """ component LoginForm: Button("Submit"): on_click: state.loading = True Fetch(url="/api/login", method="POST") """.trimIndent() val result = NanoDSL.parse(source) val button = result.children[0] as NanoNode.Button assertNotNull(button.onClick) // Should be a Sequence with 2 actions assertTrue(button.onClick is NanoAction.Sequence) val sequence = button.onClick as NanoAction.Sequence assertEquals(2, sequence.actions.size) // First action is state mutation assertTrue(sequence.actions[0] is NanoAction.StateMutation) val mutation = sequence.actions[0] as NanoAction.StateMutation assertEquals("loading", mutation.path) // Second action is Fetch assertTrue(sequence.actions[1] is NanoAction.Fetch) val fetch = sequence.actions[1] as NanoAction.Fetch assertEquals("/api/login", fetch.url) assertEquals(HttpMethod.POST, fetch.method) } @Test fun `should parse multi-line Fetch with callbacks`() { val source = """ component LoginForm: Button("Submit"): on_click: Fetch( url="/api/login", method="POST", body={"email": state.email, "password": state.password}, on_success: Navigate(to="/dashboard"), on_error: ShowToast("Login failed") ) """.trimIndent() val result = NanoDSL.parse(source) val button = result.children[0] as NanoNode.Button assertNotNull(button.onClick) assertTrue(button.onClick is NanoAction.Fetch) val fetch = button.onClick as NanoAction.Fetch assertEquals("/api/login", fetch.url) assertEquals(HttpMethod.POST, fetch.method) // Check body assertNotNull(fetch.body) assertEquals(2, fetch.body!!.size) // Check callbacks assertNotNull(fetch.onSuccess) assertTrue(fetch.onSuccess is NanoAction.Navigate) assertEquals("/dashboard", (fetch.onSuccess as NanoAction.Navigate).to) assertNotNull(fetch.onError) assertTrue(fetch.onError is NanoAction.ShowToast) assertEquals("Login failed", (fetch.onError as NanoAction.ShowToast).message) } @Test fun `should parse AI generated contact form with HTTP request`() { val source = """ component ContactForm: state: name: str = "" email: str = "" message: str = "" is_submitting: bool = False Card: VStack(spacing="md"): Text("Contact Us", style="h2") Input(value:=state.name, placeholder="Enter your name") Input(value:=state.email, placeholder="Enter your email") Button("Send Message", intent="primary"): on_click: state.is_submitting = True Fetch( url="/api/contact", method="POST", body={"name": state.name, "email": state.email, "message": state.message}, headers={"Content-Type": "application/json"}, on_success: ShowToast("Message sent!"), on_error: ShowToast("Failed to send") ) """.trimIndent() val result = NanoDSL.parse(source) // Should parse successfully assertEquals("ContactForm", result.name) // Check state assertNotNull(result.state) assertTrue(result.state!!.variables.any { it.name == "name" }) assertTrue(result.state!!.variables.any { it.name == "is_submitting" }) // Find the Card val card = result.children[0] as NanoNode.Card val vstack = card.children[0] as NanoNode.VStack // Find the button val button = vstack.children.filterIsInstance().firstOrNull() assertNotNull(button) assertEquals("Send Message", button!!.label) // Check onClick action (should be Sequence with state mutation + Fetch) assertNotNull(button.onClick) assertTrue(button.onClick is NanoAction.Sequence) val sequence = button.onClick as NanoAction.Sequence assertTrue(sequence.actions.size >= 2) // Find Fetch action val fetchAction = sequence.actions.filterIsInstance().firstOrNull() assertNotNull(fetchAction) assertEquals("/api/contact", fetchAction!!.url) assertEquals(HttpMethod.POST, fetchAction.method) } @Test fun `should parse state variables with empty string default values correctly`() { val source = """ component LoginForm: state: email: str = "" password: str = "" username: str = "default" loading: bool = False count: int = 0 VStack: Text("Login") """.trimIndent() val result = NanoDSL.parse(source) // Check state was parsed assertNotNull(result.state) val state = result.state!! // Check that empty string default values are correctly stripped of quotes val emailVar = state.variables.find { it.name == "email" } assertNotNull(emailVar) assertEquals("str", emailVar!!.type) assertEquals("", emailVar.defaultValue, "Empty string should have quotes stripped") val passwordVar = state.variables.find { it.name == "password" } assertNotNull(passwordVar) assertEquals("", passwordVar!!.defaultValue, "Empty string should have quotes stripped") // Check that non-empty string values are also correctly stripped val usernameVar = state.variables.find { it.name == "username" } assertNotNull(usernameVar) assertEquals("default", usernameVar!!.defaultValue, "String value should have quotes stripped") // Check that non-string values are preserved as-is val loadingVar = state.variables.find { it.name == "loading" } assertNotNull(loadingVar) assertEquals("False", loadingVar!!.defaultValue) val countVar = state.variables.find { it.name == "count" } assertNotNull(countVar) assertEquals("0", countVar!!.defaultValue) } @Test fun `should render state with empty string correctly in IR`() { val source = """ component LoginForm: state: email: str = "" password: str = "" Card: Input(value:=state.email, placeholder="Email") """.trimIndent() val result = NanoDSL.parse(source) val ir = NanoDSL.toIR(result) // Check state in IR assertNotNull(ir.state) val emailState = ir.state!!.variables["email"] assertNotNull(emailState) assertEquals("str", emailState!!.type) // The default value should be an empty string, not "" assertEquals("", emailState.defaultValue?.jsonPrimitive?.content) } @Test fun `should convert Select options bracket literal into JsonArray`() { val source = """ component TripPlanner: state: accommodation: str = "" Select(value := state.accommodation, options=[ {"value": "hotel", "label": "星级酒店"}, {"value": "hostel", "label": "青年旅社"} ], placeholder="选择住宿类型") """.trimIndent() val ast = NanoDSL.parse(source) val select = ast.children[0] as NanoNode.Select assertTrue(select.options!!.trim().startsWith("[")) val ir = NanoIRConverter.convert(select) val options = ir.props["options"] assertTrue(options is JsonArray) assertTrue((options as JsonArray).isNotEmpty()) val first = options[0] assertTrue(first is JsonObject) val v = (first as JsonObject)["value"]!!.jsonPrimitive.content assertTrue(v == "hotel") } /** * Test TravelPlan DSL from count-issue.dsl: * - str type alias for string * - dict type for state with dict literal initialization * - Icon component * - standalone Divider (without parentheses) * - arithmetic expression in Text (state.budget.transport + ...) * - color property */ @Test fun `should parse TravelPlan with dict state, Icon, Divider and arithmetic expression`() { val source = loadResourceText("nanodsl/count-issue.dsl") val result = NanoDSL.parse(source) // Check component name assertEquals("TravelPlan", result.name) // Check state with str and dict types assertNotNull(result.state) val state = result.state!! // Check str type val transport = state.variables.find { it.name == "transport" } assertNotNull(transport) assertEquals("str", transport!!.type) assertEquals("train", transport.defaultValue) // Check dict type with dict literal default val budget = state.variables.find { it.name == "budget" } assertNotNull(budget) assertEquals("dict", budget!!.type) assertTrue(budget.defaultValue!!.contains("transport")) assertTrue(budget.defaultValue!!.contains("800")) // Check children structure val vstack = result.children[0] as NanoNode.VStack assertEquals("lg", vstack.spacing) val card = vstack.children[0] as NanoNode.Card assertEquals("md", card.padding) assertEquals("sm", card.shadow) } /** * Test Icon component parsing */ @Test fun `should parse Icon component with size and color`() { val source = """ component IconTest: HStack: Icon("home", size="lg", color="primary") Icon("settings", size="md") Icon("user") """.trimIndent() val result = NanoDSL.parse(source) val hstack = result.children[0] as NanoNode.HStack assertEquals(3, hstack.children.size) val icon1 = hstack.children[0] as NanoNode.Icon assertEquals("home", icon1.name) assertEquals("lg", icon1.size) assertEquals("primary", icon1.color) val icon2 = hstack.children[1] as NanoNode.Icon assertEquals("settings", icon2.name) assertEquals("md", icon2.size) assertNull(icon2.color) val icon3 = hstack.children[2] as NanoNode.Icon assertEquals("user", icon3.name) assertNull(icon3.size) assertNull(icon3.color) } /** * Test standalone Divider (without parentheses) */ @Test fun `should parse standalone Divider without parentheses`() { val source = """ component DividerTest: VStack: Text("Section 1") Divider Text("Section 2") Divider Text("Section 3") """.trimIndent() val result = NanoDSL.parse(source) val vstack = result.children[0] as NanoNode.VStack assertEquals(5, vstack.children.size) assertTrue(vstack.children[0] is NanoNode.Text) assertTrue(vstack.children[1] is NanoNode.Divider) assertTrue(vstack.children[2] is NanoNode.Text) assertTrue(vstack.children[3] is NanoNode.Divider) assertTrue(vstack.children[4] is NanoNode.Text) } }