Skip to content

Commit b7375cb

Browse files
codebutlerclaude
andcommitted
Add regression tests for FeliCa iOS multi-system workaround
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4f5621e commit b7375cb

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

card/felica/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ kotlin {
2424
iosSimulatorArm64()
2525

2626
sourceSets {
27+
commonTest.dependencies {
28+
implementation(kotlin("test"))
29+
}
2730
commonMain.dependencies {
2831
implementation(libs.compose.resources)
2932
implementation(libs.compose.runtime)
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*
2+
* FeliCaReaderTest.kt
3+
*
4+
* This file is part of FareBot.
5+
* Learn more at: https://codebutler.github.io/farebot/
6+
*
7+
* Copyright (C) 2026 Eric Butler <eric@codebutler.com>
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU General Public License as published by
11+
* the Free Software Foundation, either version 3 of the License, or
12+
* (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*/
22+
23+
package com.codebutler.farebot.card.felica
24+
25+
import kotlin.test.Test
26+
import kotlin.test.assertEquals
27+
import kotlin.test.assertFalse
28+
import kotlin.test.assertTrue
29+
30+
/**
31+
* Tests for [FeliCaReader], specifically the `onlyFirst` parameter
32+
* added as a workaround for an iOS CoreNFC multi-system bug.
33+
*/
34+
class FeliCaReaderTest {
35+
private val testTagId = ByteArray(8) { it.toByte() }
36+
private val testIdm = ByteArray(8) { (0x01 + it).toByte() }
37+
private val testPmm = ByteArray(8) { (0x10 + it).toByte() }
38+
private val testBlockData = ByteArray(16) { 0xAA.toByte() }
39+
40+
/**
41+
* Fake adapter simulating a FeliCa tag with multiple system codes,
42+
* each having one service with one block.
43+
*/
44+
private class FakeAdapter(
45+
private val idm: ByteArray,
46+
private val pmm: ByteArray,
47+
private val systemCodes: List<Int>,
48+
private val serviceCodes: Map<Int, List<Int>>,
49+
private val blockData: ByteArray,
50+
) : FeliCaTagAdapter {
51+
val selectedSystems = mutableListOf<Int>()
52+
val readServiceCodes = mutableListOf<Int>()
53+
54+
override fun getIDm(): ByteArray = idm
55+
56+
override fun getSystemCodes(): List<Int> = systemCodes
57+
58+
override fun selectSystem(systemCode: Int): ByteArray? {
59+
selectedSystems.add(systemCode)
60+
return pmm
61+
}
62+
63+
override fun getServiceCodes(): List<Int> =
64+
serviceCodes[selectedSystems.last()] ?: emptyList()
65+
66+
override fun readBlock(serviceCode: Int, blockAddr: Byte): ByteArray? {
67+
readServiceCodes.add(serviceCode)
68+
// Return data only for block 0; return null for block 1+ to end iteration
69+
return if (blockAddr.toInt() == 0) blockData else null
70+
}
71+
}
72+
73+
@Test
74+
fun testDefaultReadsAllSystems() {
75+
val adapter = FakeAdapter(
76+
idm = testIdm,
77+
pmm = testPmm,
78+
systemCodes = listOf(0x0003, 0x8008),
79+
serviceCodes = mapOf(
80+
0x0003 to listOf(0x090F),
81+
0x8008 to listOf(0x0117),
82+
),
83+
blockData = testBlockData,
84+
)
85+
86+
val result = FeliCaReader.readTag(testTagId, adapter)
87+
88+
assertEquals(2, result.systems.size)
89+
90+
// Both systems should be fully read (not skipped)
91+
assertFalse(result.systems[0].skipped)
92+
assertFalse(result.systems[1].skipped)
93+
94+
// Both systems should have services
95+
assertEquals(1, result.systems[0].services.size)
96+
assertEquals(1, result.systems[1].services.size)
97+
98+
assertEquals(0x0003, result.systems[0].code)
99+
assertEquals(0x8008, result.systems[1].code)
100+
}
101+
102+
@Test
103+
fun testOnlyFirstReadsFirstSystemOnly() {
104+
val adapter = FakeAdapter(
105+
idm = testIdm,
106+
pmm = testPmm,
107+
systemCodes = listOf(0x0003, 0x8008),
108+
serviceCodes = mapOf(
109+
0x0003 to listOf(0x090F),
110+
0x8008 to listOf(0x0117),
111+
),
112+
blockData = testBlockData,
113+
)
114+
115+
val result = FeliCaReader.readTag(testTagId, adapter, onlyFirst = true)
116+
117+
assertEquals(2, result.systems.size)
118+
119+
// First system should be fully read
120+
assertFalse(result.systems[0].skipped)
121+
assertEquals(1, result.systems[0].services.size)
122+
assertEquals(0x0003, result.systems[0].code)
123+
124+
// Second system should be skipped with no services
125+
assertTrue(result.systems[1].skipped)
126+
assertEquals(0, result.systems[1].services.size)
127+
assertEquals(0x8008, result.systems[1].code)
128+
}
129+
130+
@Test
131+
fun testOnlyFirstDoesNotCallAdapterForSkippedSystems() {
132+
val adapter = FakeAdapter(
133+
idm = testIdm,
134+
pmm = testPmm,
135+
systemCodes = listOf(0x0003, 0x8008, 0xFE00),
136+
serviceCodes = mapOf(
137+
0x0003 to listOf(0x090F),
138+
0x8008 to listOf(0x0117),
139+
0xFE00 to listOf(0x000B),
140+
),
141+
blockData = testBlockData,
142+
)
143+
144+
FeliCaReader.readTag(testTagId, adapter, onlyFirst = true)
145+
146+
// selectSystem is called for:
147+
// 1. PMm poll with first system code (0x0003)
148+
// 2. Select system 0x0003 for reading
149+
// 3. Re-select system 0x0003 before reading service 0x090F
150+
// It should NOT be called for systems 0x8008 or 0xFE00
151+
val selectCalls = adapter.selectedSystems
152+
assertTrue(selectCalls.none { it == 0x8008 })
153+
assertTrue(selectCalls.none { it == 0xFE00 })
154+
155+
// readBlock should only be called for the first system's service
156+
assertTrue(adapter.readServiceCodes.all { it == 0x090F })
157+
}
158+
159+
@Test
160+
fun testOnlyFirstWithSingleSystemBehavesLikeDefault() {
161+
val adapterDefault = FakeAdapter(
162+
idm = testIdm,
163+
pmm = testPmm,
164+
systemCodes = listOf(0x0003),
165+
serviceCodes = mapOf(0x0003 to listOf(0x090F)),
166+
blockData = testBlockData,
167+
)
168+
169+
val adapterOnlyFirst = FakeAdapter(
170+
idm = testIdm,
171+
pmm = testPmm,
172+
systemCodes = listOf(0x0003),
173+
serviceCodes = mapOf(0x0003 to listOf(0x090F)),
174+
blockData = testBlockData,
175+
)
176+
177+
val resultDefault = FeliCaReader.readTag(testTagId, adapterDefault)
178+
val resultOnlyFirst = FeliCaReader.readTag(testTagId, adapterOnlyFirst, onlyFirst = true)
179+
180+
assertEquals(resultDefault.systems.size, resultOnlyFirst.systems.size)
181+
assertEquals(1, resultOnlyFirst.systems.size)
182+
assertFalse(resultOnlyFirst.systems[0].skipped)
183+
assertEquals(
184+
resultDefault.systems[0].services.size,
185+
resultOnlyFirst.systems[0].services.size,
186+
)
187+
}
188+
189+
@Test
190+
fun testOnlyFirstWithThreeSystemsSkipsSecondAndThird() {
191+
val adapter = FakeAdapter(
192+
idm = testIdm,
193+
pmm = testPmm,
194+
systemCodes = listOf(0x0003, 0x8008, 0xFE00),
195+
serviceCodes = mapOf(
196+
0x0003 to listOf(0x090F),
197+
0x8008 to listOf(0x0117),
198+
0xFE00 to listOf(0x000B),
199+
),
200+
blockData = testBlockData,
201+
)
202+
203+
val result = FeliCaReader.readTag(testTagId, adapter, onlyFirst = true)
204+
205+
assertEquals(3, result.systems.size)
206+
207+
// First system fully read
208+
assertFalse(result.systems[0].skipped)
209+
assertEquals(1, result.systems[0].services.size)
210+
211+
// Second and third systems skipped
212+
assertTrue(result.systems[1].skipped)
213+
assertEquals(0, result.systems[1].services.size)
214+
215+
assertTrue(result.systems[2].skipped)
216+
assertEquals(0, result.systems[2].services.size)
217+
}
218+
219+
@Test
220+
fun testFelicaSystemSkippedFactory() {
221+
val system = FelicaSystem.skipped(0x8008)
222+
223+
assertTrue(system.skipped)
224+
assertEquals(0x8008, system.code)
225+
assertEquals(0, system.services.size)
226+
}
227+
}

0 commit comments

Comments
 (0)