Skip to content

Commit a934844

Browse files
committed
test: TerminalView queue suite (vitest) and android pure-logic units (junit)
1 parent 0edf426 commit a934844

10 files changed

Lines changed: 1302 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
- run: pnpm install --frozen-lockfile
2323
- run: pnpm lint
2424
- run: pnpm exec tsc --noEmit
25+
- run: pnpm test
2526
- run: pnpm clean && pnpm build
2627

2728
android:
@@ -52,6 +53,10 @@ jobs:
5253
- name: Prebuild example android project
5354
run: pnpm --dir example exec expo prebuild --platform android --no-install
5455

56+
- name: Run module unit tests
57+
working-directory: example/android
58+
run: ./gradlew :expo-libghostty:testDebugUnitTest
59+
5560
# Release with minification exercises R8 over the module's reflection
5661
# (expo-modules registration, Records) and JNI surface, not just compilation.
5762
- name: Build example app (release, R8 minified)

.npmignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@
2020
/eslint.config.cjs
2121
/pnpm-workspace.yaml
2222
/tsconfig.json
23+
24+
# Tests never ship
25+
/vitest.config.ts
26+
/src/__tests__/
27+
/android/src/test/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010

1111
### 💡 Others
1212

13+
- First automated test suites: vitest covers the TerminalView imperative
14+
queue (the 0.8.1 mount-race contract), and JUnit covers the Android
15+
snapshot wire format, cell color resolution, and the sticky-modifier state
16+
machine (extracted into `SnapshotFormat.kt` / `StickyModifier.kt`). Both
17+
run in CI.
18+
1319
## 0.8.1 — 2026-07-17
1420

1521
### 🐛 Bug fixes

android/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@ android {
3535
}
3636
}
3737
}
38+
39+
dependencies {
40+
testImplementation 'junit:junit:4.13.2'
41+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package expo.modules.libghostty
2+
3+
import java.nio.ByteBuffer
4+
import java.nio.ByteOrder
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertFalse
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
10+
class SnapshotFormatTest {
11+
// Mirrors the writer in ghostty_jni.cpp: 13×i32, little-endian.
12+
private fun headerBuffer(vararg fields: Int): ByteBuffer {
13+
val buf = ByteBuffer.allocate(HEADER_BYTES + 8).order(ByteOrder.LITTLE_ENDIAN)
14+
for (field in fields) buf.putInt(field)
15+
buf.position(0)
16+
return buf
17+
}
18+
19+
@Test
20+
fun `header size matches the declared v2 layout`() {
21+
assertEquals(13 * Int.SIZE_BYTES, HEADER_BYTES)
22+
}
23+
24+
@Test
25+
fun `read decodes every header field and consumes exactly the header`() {
26+
val buf = headerBuffer(
27+
2, // version
28+
DIRTY_FULL,
29+
80, // cols
30+
24, // rows
31+
0xFF1E1E2E.toInt(), // background
32+
0xFFCDD6F4.toInt(), // foreground
33+
12, // cursorX
34+
3, // cursorY
35+
CURSOR_STYLE_BLOCK,
36+
1, // cursorVisible
37+
24, // rowCount
38+
1, // cursorBlinks
39+
0xFFF5C2E7.toInt(), // cursorColor
40+
)
41+
val header = SnapshotHeader.read(buf)
42+
assertEquals(2, header.version)
43+
assertEquals(DIRTY_FULL, header.dirtyKind)
44+
assertEquals(80, header.cols)
45+
assertEquals(24, header.rows)
46+
assertEquals(0xFF1E1E2E.toInt(), header.background)
47+
assertEquals(0xFFCDD6F4.toInt(), header.foreground)
48+
assertEquals(12, header.cursorX)
49+
assertEquals(3, header.cursorY)
50+
assertEquals(CURSOR_STYLE_BLOCK, header.cursorStyle)
51+
assertTrue(header.cursorVisible)
52+
assertEquals(24, header.rowCount)
53+
assertTrue(header.cursorBlinks)
54+
assertEquals(0xFFF5C2E7.toInt(), header.cursorColor)
55+
assertEquals(HEADER_BYTES, buf.position())
56+
}
57+
58+
@Test
59+
fun `zero int fields decode as false and unconfigured cursor color`() {
60+
val buf = headerBuffer(2, 0, 80, 24, 0, 0, 0, 0, CURSOR_STYLE_BAR, 0, 0, 0, 0)
61+
val header = SnapshotHeader.read(buf)
62+
assertFalse(header.cursorVisible)
63+
assertFalse(header.cursorBlinks)
64+
assertEquals(0, header.cursorColor)
65+
}
66+
}
67+
68+
class ResolveCellColorsTest {
69+
private val fg = 0xFFAA0000.toInt()
70+
private val bg = 0xFF00AA00.toInt()
71+
private val defaultFg = 0xFFEEEEEE.toInt()
72+
private val defaultBg = 0xFF111111.toInt()
73+
private val themeFg = 0xFF101010.toInt()
74+
private val themeBg = 0xFFF0F0F0.toInt()
75+
76+
private fun resolve(
77+
flags: Int = 0,
78+
selected: Boolean = false,
79+
selectionFg: Int? = null,
80+
selectionBg: Int? = null,
81+
) = resolveCellColors(fg, bg, flags, selected, defaultFg, defaultBg, selectionFg, selectionBg)
82+
83+
private fun assertColors(expectedFg: Int, expectedBg: Int, packed: Long) {
84+
assertEquals(expectedFg, packedFg(packed))
85+
assertEquals(expectedBg, packedBg(packed))
86+
}
87+
88+
@Test
89+
fun `explicit colors pass through`() {
90+
assertColors(fg, bg, resolve())
91+
}
92+
93+
@Test
94+
fun `default flags substitute the terminal defaults`() {
95+
assertColors(defaultFg, bg, resolve(flags = FLAG_FG_DEFAULT))
96+
assertColors(fg, defaultBg, resolve(flags = FLAG_BG_NONE))
97+
assertColors(defaultFg, defaultBg, resolve(flags = FLAG_FG_DEFAULT or FLAG_BG_NONE))
98+
}
99+
100+
@Test
101+
fun `inverse swaps the resolved colors`() {
102+
assertColors(bg, fg, resolve(flags = FLAG_INVERSE))
103+
// Defaults resolve first, then swap.
104+
assertColors(defaultBg, defaultFg, resolve(flags = FLAG_FG_DEFAULT or FLAG_BG_NONE or FLAG_INVERSE))
105+
}
106+
107+
@Test
108+
fun `selection without a theme swaps like inverse`() {
109+
assertColors(bg, fg, resolve(selected = true))
110+
}
111+
112+
@Test
113+
fun `selection on an inverse cell swaps back to the original colors`() {
114+
assertColors(fg, bg, resolve(flags = FLAG_INVERSE, selected = true))
115+
}
116+
117+
@Test
118+
fun `themed selection background keeps the cell foreground`() {
119+
assertColors(fg, themeBg, resolve(selected = true, selectionBg = themeBg))
120+
}
121+
122+
@Test
123+
fun `themed selection foreground applies only with a themed background`() {
124+
assertColors(themeFg, themeBg, resolve(selected = true, selectionFg = themeFg, selectionBg = themeBg))
125+
// Foreground alone falls back to the classic swap.
126+
assertColors(bg, fg, resolve(selected = true, selectionFg = themeFg))
127+
}
128+
129+
@Test
130+
fun `themed selection overrides an inverse cell`() {
131+
assertColors(themeFg, themeBg, resolve(flags = FLAG_INVERSE, selected = true, selectionFg = themeFg, selectionBg = themeBg))
132+
}
133+
134+
@Test
135+
fun `unselected cells ignore the selection theme`() {
136+
assertColors(fg, bg, resolve(selectionFg = themeFg, selectionBg = themeBg))
137+
}
138+
139+
@Test
140+
fun `packing round-trips negative ARGB ints`() {
141+
val packed = resolveCellColors(
142+
0x80000000.toInt(), 0xFFFFFFFF.toInt(), 0, false,
143+
defaultFg, defaultBg, null, null
144+
)
145+
assertColors(0x80000000.toInt(), 0xFFFFFFFF.toInt(), packed)
146+
}
147+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package expo.modules.libghostty
2+
3+
import expo.modules.libghostty.StickyModifier.Activation
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertFalse
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class StickyModifierTest {
10+
private var clock = 0L
11+
private val modifier = StickyModifier { clock }
12+
13+
@Test
14+
fun `tap arms an inactive modifier`() {
15+
modifier.tap()
16+
assertEquals(Activation.ARMED, modifier.activation)
17+
assertTrue(modifier.engaged)
18+
assertFalse(modifier.locked)
19+
}
20+
21+
@Test
22+
fun `consume unlatches an armed modifier`() {
23+
modifier.tap()
24+
modifier.consume()
25+
assertEquals(Activation.INACTIVE, modifier.activation)
26+
assertFalse(modifier.engaged)
27+
}
28+
29+
@Test
30+
fun `consume keeps an inactive modifier inactive`() {
31+
modifier.consume()
32+
assertEquals(Activation.INACTIVE, modifier.activation)
33+
}
34+
35+
@Test
36+
fun `double tap within the window locks`() {
37+
modifier.tap()
38+
clock += StickyModifier.DOUBLE_TAP_MS - 1
39+
modifier.tap()
40+
assertEquals(Activation.LOCKED, modifier.activation)
41+
assertTrue(modifier.locked)
42+
}
43+
44+
@Test
45+
fun `slow second tap deactivates instead of locking`() {
46+
modifier.tap()
47+
clock += StickyModifier.DOUBLE_TAP_MS
48+
modifier.tap()
49+
assertEquals(Activation.INACTIVE, modifier.activation)
50+
}
51+
52+
@Test
53+
fun `consume does not release a locked modifier`() {
54+
modifier.tap()
55+
clock += 1
56+
modifier.tap()
57+
modifier.consume()
58+
assertEquals(Activation.LOCKED, modifier.activation)
59+
}
60+
61+
@Test
62+
fun `tap releases a locked modifier`() {
63+
modifier.tap()
64+
clock += 1
65+
modifier.tap()
66+
clock += 1
67+
modifier.tap()
68+
assertEquals(Activation.INACTIVE, modifier.activation)
69+
}
70+
71+
@Test
72+
fun `quick re-tap after consumption arms instead of locking`() {
73+
modifier.tap()
74+
modifier.consume()
75+
clock += 1
76+
modifier.tap()
77+
assertEquals(Activation.ARMED, modifier.activation)
78+
}
79+
80+
@Test
81+
fun `arming again after a lock cycle starts from armed`() {
82+
modifier.tap() // armed
83+
clock += 1
84+
modifier.tap() // locked
85+
clock += 1
86+
modifier.tap() // inactive
87+
clock += StickyModifier.DOUBLE_TAP_MS + 1
88+
modifier.tap()
89+
assertEquals(Activation.ARMED, modifier.activation)
90+
}
91+
}

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"build": "node internal/module_scripts/build.js",
99
"clean": "node internal/module_scripts/clean.js",
1010
"lint": "eslint src/",
11+
"test": "vitest run",
1112
"prepare": "node internal/module_scripts/prepare.js",
1213
"postinstall": "node scripts/download-xcframework.mjs && node scripts/download-android-libs.mjs",
1314
"sync-vendor": "./scripts/sync-vendor.sh",
@@ -34,9 +35,13 @@
3435
"eslint": "~9.39.4",
3536
"eslint-config-universe": "^15.0.3",
3637
"expo": "^57.0.5",
38+
"jsdom": "^29.1.1",
3739
"prettier": "^3.0.0",
40+
"react": "^19.2.3",
41+
"react-dom": "19.2.3",
3842
"react-native": "0.86.0",
39-
"typescript": "^5.9.2"
43+
"typescript": "^5.9.2",
44+
"vitest": "^4.1.10"
4045
},
4146
"peerDependencies": {
4247
"expo": "*",

0 commit comments

Comments
 (0)