Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import halogen.HalogenConfig
Expand All @@ -27,6 +28,17 @@ class HalogenThemeTest {
"""{"pri":"#9A6ACD","sec":"#4A8A8A","ter":"#B06B7D","neuL":"#F3F0F6","neuD":"#151018","err":"#93000A","font":"mono","hw":700,"bw":400,"ls":true,"cs":"sharp","cx":0.5}""",
)

private fun ComposeUiTest.waitUntilPrimaryChangesFrom(
previousPrimary: Color,
currentPrimary: () -> Color,
) {
waitUntil(timeoutMillis = 5_000) {
val primary = currentPrimary()
primary != Color.Unspecified && primary != previousPrimary
}
waitForIdle()
}

// ── Config flag tests ───────────────────────────────────────────────

@Test
Expand All @@ -45,7 +57,7 @@ class HalogenThemeTest {
}
}

waitForIdle()
waitUntilPrimaryChangesFrom(defaultPrimary) { capturedPrimary }
assertNotEquals(defaultPrimary, capturedPrimary, "LLM colors should differ from M3 defaults")
}

Expand Down Expand Up @@ -103,6 +115,7 @@ class HalogenThemeTest {
}
}

waitUntil(timeoutMillis = 5_000) { capturedMediumShape != defaultMediumShape }
waitForIdle()
assertNotEquals(defaultMediumShape, capturedMediumShape, "LLM shapes should differ from M3 defaults")
}
Expand All @@ -114,8 +127,6 @@ class HalogenThemeTest {
var spec by mutableStateOf(oceanSpec)
var capturedPrimary = Color.Unspecified

mainClock.autoAdvance = false

setContent {
HalogenTheme(
spec = spec,
Expand All @@ -127,26 +138,13 @@ class HalogenThemeTest {
}
}

// Let initial theme expand and snap
mainClock.advanceTimeBy(1000)
waitUntilPrimaryChangesFrom(lightColorScheme().primary) { capturedPrimary }
val oceanPrimary = capturedPrimary

// Switch to neon spec
spec = neonSpec
mainClock.advanceTimeBy(50) // let expansion start
mainClock.advanceTimeBy(50) // expansion should complete

// mid-animation: color should have started changing but not reached target
mainClock.advanceTimeBy(100)
val midPrimary = capturedPrimary

// complete animation
mainClock.advanceTimeBy(500)
waitUntilPrimaryChangesFrom(oceanPrimary) { capturedPrimary }
val finalPrimary = capturedPrimary

// mid-animation should differ from both start and end
// (unless expansion is slow, in which case mid == ocean still)
// Final should differ from ocean
assertNotEquals(oceanPrimary, finalPrimary, "Final primary should differ from ocean primary after spec change")
}

Expand All @@ -155,8 +153,6 @@ class HalogenThemeTest {
var spec by mutableStateOf(oceanSpec)
var capturedPrimary = Color.Unspecified

mainClock.autoAdvance = false

setContent {
HalogenTheme(
spec = spec,
Expand All @@ -168,13 +164,11 @@ class HalogenThemeTest {
}
}

// Let initial theme expand
mainClock.advanceTimeBy(1000)
waitUntilPrimaryChangesFrom(lightColorScheme().primary) { capturedPrimary }
val oceanPrimary = capturedPrimary

// Switch to neon spec
spec = neonSpec
mainClock.advanceTimeBy(1000) // give expansion time
waitUntilPrimaryChangesFrom(oceanPrimary) { capturedPrimary }

val afterSnap = capturedPrimary

Expand All @@ -187,8 +181,6 @@ class HalogenThemeTest {
var capturedPrimary = Color.Unspecified
val defaultPrimary = lightColorScheme().primary

mainClock.autoAdvance = false

setContent {
HalogenTheme(
spec = oceanSpec,
Expand All @@ -200,8 +192,7 @@ class HalogenThemeTest {
}
}

// Advance past expansion time — first theme should snap, not animate
mainClock.advanceTimeBy(1000)
waitUntilPrimaryChangesFrom(defaultPrimary) { capturedPrimary }
val firstPrimary = capturedPrimary

// If it animated, we'd still see the default color at frame 0.
Expand Down
15 changes: 12 additions & 3 deletions halogen-core/src/commonMain/kotlin/halogen/SchemaParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ public object SchemaParser {
isLenient = true
}

private val HEX_COLOR_REGEX: Regex = Regex("^#[0-9A-Fa-f]{6}$")

/**
* Parse a JSON string (potentially wrapped in markdown code fences) into
* a validated [HalogenThemeSpec].
Expand Down Expand Up @@ -67,7 +65,7 @@ public object SchemaParser {
)

for ((name, value) in colorFields) {
if (!HEX_COLOR_REGEX.matches(value)) {
if (!isValidHexColor(value)) {
return Result.failure(
IllegalArgumentException(
"Invalid hex color for $name: \"$value\". Expected format: #RRGGBB",
Expand All @@ -85,4 +83,15 @@ public object SchemaParser {

return Result.success(clamped)
}

private fun isValidHexColor(value: String): Boolean {
if (value.length != 7 || value[0] != '#') return false

for (index in 1 until value.length) {
val char = value[index]
if (char !in '0'..'9' && char !in 'a'..'f' && char !in 'A'..'F') return false
}

return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ class SchemaParserTest {
assertTrue(result.isFailure, "Should fail on short hex color")
}

@Test
fun parse_hexColorWithoutHash_fails() {
val badColor = validJson.replace("#6750A4", "6750A4")
val result = SchemaParser.parse(badColor)
assertTrue(result.isFailure, "Should fail when hex color is missing leading #")
}

// ---- Clamping: weights ----

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,79 @@ package halogen.engine
*/
internal object HintExtractor {

private val PREFIX_PATTERN = Regex("""^(?:/r/|/category/|/topic/|/|#)""")
private val CAMEL_SPLIT = Regex("""(?<=[a-z])(?=[A-Z])""")
private val ID_PATTERN = Regex("""^[0-9a-f]{8,}$""", RegexOption.IGNORE_CASE)
private val NUMERIC_ONLY = Regex("""^\d+$""")
private val WHITESPACE_PATTERN = Regex("""\s+""")

fun extract(key: String): String? {
if (key.isBlank()) return null

// Strip common prefixes
var cleaned = PREFIX_PATTERN.replace(key.trim(), "")
val trimmed = key.trim()
var start = when {
trimmed.startsWith("/r/") -> 3
trimmed.startsWith("/category/") -> 10
trimmed.startsWith("/topic/") -> 7
trimmed.startsWith("/") -> 1
trimmed.startsWith("#") -> 1
else -> 0
}

// Remove leading/trailing slashes
cleaned = cleaned.trim('/')
var end = trimmed.length
while (start < end && trimmed[start] == '/') start++
while (end > start && trimmed[end - 1] == '/') end--
if (start >= end) return null

// Take the last meaningful segment if it looks like a path
if ('/' in cleaned) {
cleaned = cleaned.substringAfterLast('/')
val lastSlash = trimmed.lastIndexOf('/', end - 1)
if (lastSlash >= start) {
start = lastSlash + 1
}

// Split camelCase
cleaned = CAMEL_SPLIT.replace(cleaned, " ")
val builder = StringBuilder(end - start)
var previousOutput = ' '
var previousOriginal = ' '

for (index in start until end) {
val char = trimmed[index]

// Split snake_case and kebab-case
cleaned = cleaned.replace('_', ' ').replace('-', ' ')
if (char == '_' || char == '-' || char.isWhitespace()) {
if (previousOutput != ' ') {
builder.append(' ')
previousOutput = ' '
}
} else {
if (previousOriginal in 'a'..'z' && char in 'A'..'Z' && previousOutput != ' ') {
builder.append(' ')
}
builder.append(char)
previousOutput = char
}

// Normalize whitespace
cleaned = cleaned.trim().replace(WHITESPACE_PATTERN, " ")
previousOriginal = char
}

val cleaned = builder.toString().trim()

if (cleaned.isBlank()) return null

// Reject things that look like IDs
val noSpaces = cleaned.replace(" ", "")
if (ID_PATTERN.matches(noSpaces)) return null
if (NUMERIC_ONLY.matches(noSpaces)) return null
if (looksLikeId(cleaned)) return null

return cleaned.lowercase()
}

private fun looksLikeId(value: String): Boolean {
var length = 0
var allNumeric = true
var allHex = true

for (char in value) {
if (char == ' ') continue

length++
if (char !in '0'..'9') allNumeric = false
if (char !in '0'..'9' && char !in 'a'..'f' && char !in 'A'..'F') allHex = false

if (!allNumeric && !allHex) return false
}

return length > 0 && (allNumeric || (length >= 8 && allHex))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,29 @@ class HintExtractorTest {
fun extract_pathTakesLastSegment() {
assertEquals("programming", HintExtractor.extract("/category/tech/programming"))
}

@Test
fun extract_topicPrefix_strips() {
assertEquals("material you", HintExtractor.extract("/topic/material-you"))
}

@Test
fun extract_hashPrefix_strips() {
assertEquals("dark mode", HintExtractor.extract("#darkMode"))
}

@Test
fun extract_repeatedSeparators_normalizesWhitespace() {
assertEquals("dark mode", HintExtractor.extract(" dark__-- \t mode "))
}

@Test
fun extract_numericOnly_returnsNull() {
assertNull(HintExtractor.extract("123456"))
}

@Test
fun extract_hexIdWithSeparators_returnsNull() {
assertNull(HintExtractor.extract("a1b2-c3d4"))
}
}
Loading