Skip to content

Commit aaad6fd

Browse files
committed
dataconnect: relax LocalDateSerializer encoding and decoding, and add unit test coverage
1 parent f20340a commit aaad6fd

File tree

8 files changed

+775
-15
lines changed

8 files changed

+775
-15
lines changed

firebase-dataconnect/firebase-dataconnect.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ dependencies {
123123
testImplementation(libs.kotlinx.datetime)
124124
testImplementation(libs.kotlinx.serialization.json)
125125
testImplementation(libs.mockk)
126+
testImplementation(libs.testonly.three.ten.abp)
126127
testImplementation(libs.robolectric)
127128

128129
androidTestImplementation(project(":firebase-dataconnect:androidTestutil"))

firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializer.kt

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,48 @@ public object LocalDateSerializer : KSerializer<LocalDate> {
3636
PrimitiveSerialDescriptor("com.google.firebase.dataconnect.LocalDate", PrimitiveKind.STRING)
3737

3838
override fun serialize(encoder: Encoder, value: LocalDate) {
39-
value.run {
40-
require(year >= 0) { "invalid value: $value (year must be non-negative)" }
41-
require(month >= 0) { "invalid value: $value (month must be non-negative)" }
42-
require(day >= 0) { "invalid value: $value (day must be non-negative)" }
43-
}
44-
val serializedDate =
45-
"${value.year}".padStart(4, '0') +
46-
'-' +
47-
"${value.month}".padStart(2, '0') +
48-
'-' +
49-
"${value.day}".padStart(2, '0')
39+
val serializedDate: String = serializeToString(value)
5040
encoder.encodeString(serializedDate)
5141
}
5242

5343
override fun deserialize(decoder: Decoder): LocalDate {
5444
val decodedString = decoder.decodeString()
55-
val matcher = Pattern.compile("^(\\d+)-(\\d+)-(\\d+)$").matcher(decodedString)
45+
return deserializeToLocalDate(decodedString)
46+
}
47+
48+
private val decodeRegexPattern = Pattern.compile("^(-?\\d+)-(-?\\d+)-(-?\\d+)$")
49+
50+
private fun deserializeToLocalDate(string: String): LocalDate {
51+
val matcher = decodeRegexPattern.matcher(string)
5652
require(matcher.matches()) {
57-
"date \"$decodedString\" does not match regular expression: ${matcher.pattern()}"
53+
"date \"$string\" does not match regular expression: ${matcher.pattern()}"
5854
}
5955

6056
fun Matcher.groupToIntIgnoringLeadingZeroes(index: Int): Int {
61-
val groupText = group(index)!!.trimStart('0')
62-
return if (groupText.isEmpty()) 0 else groupText.toInt()
57+
val groupText =
58+
group(index)
59+
?: throw IllegalStateException(
60+
"internal error: group(index) should not be null " +
61+
" (index=$index, string=$string, matcher=$this, error code hp48d53pbb)"
62+
)
63+
64+
val isNegative = groupText.firstOrNull() == '-'
65+
66+
val zeroPaddedString =
67+
if (isNegative) {
68+
groupText.substring(1)
69+
} else {
70+
groupText
71+
}
72+
73+
val intAbsString = zeroPaddedString.trimStart('0')
74+
val intStringPrefix = if (isNegative) "-" else ""
75+
val intString = intStringPrefix + intAbsString
76+
if (intString.isEmpty()) {
77+
return 0
78+
}
79+
80+
return intString.toInt()
6381
}
6482

6583
val year = matcher.groupToIntIgnoringLeadingZeroes(1)
@@ -68,4 +86,33 @@ public object LocalDateSerializer : KSerializer<LocalDate> {
6886

6987
return LocalDate(year = year, month = month, day = day)
7088
}
89+
90+
private fun serializeToString(localDate: LocalDate): String {
91+
val yearStr = localDate.year.toZeroPaddedString(length = 4)
92+
val monthStr = localDate.month.toZeroPaddedString(length = 2)
93+
val dayStr = localDate.day.toZeroPaddedString(length = 2)
94+
return "$yearStr-$monthStr-$dayStr"
95+
}
96+
97+
private fun Int.toZeroPaddedString(length: Int): String = buildString {
98+
append(this@toZeroPaddedString)
99+
100+
val firstChar =
101+
firstOrNull()?.let {
102+
if (it == '-') {
103+
deleteCharAt(0)
104+
it
105+
} else {
106+
null
107+
}
108+
}
109+
110+
while (this.length < length) {
111+
insert(0, '0')
112+
}
113+
114+
if (firstChar != null) {
115+
insert(0, firstChar)
116+
}
117+
}
71118
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@file:OptIn(ExperimentalKotest::class)
18+
19+
package com.google.firebase.dataconnect
20+
21+
import com.google.firebase.dataconnect.testutil.property.arbitrary.threeTenBp
22+
import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText
23+
import com.google.firebase.dataconnect.testutil.toDataConnectLocalDate
24+
import com.google.firebase.dataconnect.testutil.toJavaTimeLocalDate
25+
import com.google.firebase.dataconnect.testutil.toKotlinxDatetimeLocalDate
26+
import io.kotest.assertions.assertSoftly
27+
import io.kotest.assertions.withClue
28+
import io.kotest.common.ExperimentalKotest
29+
import io.kotest.matchers.shouldBe
30+
import io.kotest.matchers.shouldNotBe
31+
import io.kotest.matchers.string.shouldEndWith
32+
import io.kotest.matchers.string.shouldStartWith
33+
import io.kotest.property.Arb
34+
import io.kotest.property.PropTestConfig
35+
import io.kotest.property.arbitrary.int
36+
import io.kotest.property.arbitrary.of
37+
import io.kotest.property.assume
38+
import io.kotest.property.checkAll
39+
import kotlinx.coroutines.test.runTest
40+
import kotlinx.datetime.number
41+
import org.junit.Test
42+
43+
@Suppress("ReplaceCallWithBinaryOperator")
44+
class LocalDateUnitTest {
45+
46+
@Test
47+
fun `constructor() should set properties to corresponding arguments`() = runTest {
48+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
49+
val localDate = LocalDate(year = year, month = month, day = day)
50+
assertSoftly {
51+
withClue("year") { localDate.year shouldBe year }
52+
withClue("month") { localDate.month shouldBe month }
53+
withClue("day") { localDate.day shouldBe day }
54+
}
55+
}
56+
}
57+
58+
@Test
59+
fun `equals() should return true when invoked with itself`() = runTest {
60+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
61+
val localDate = LocalDate(year = year, month = month, day = day)
62+
localDate.equals(localDate) shouldBe true
63+
}
64+
}
65+
66+
@Test
67+
fun `equals() should return true when invoked with a distinct, but equal, instance`() = runTest {
68+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
69+
val localDate1 = LocalDate(year = year, month = month, day = day)
70+
val localDate2 = LocalDate(year = year, month = month, day = day)
71+
localDate1.equals(localDate2) shouldBe true
72+
}
73+
}
74+
75+
@Test
76+
fun `equals() should return false when invoked with null`() = runTest {
77+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
78+
val localDate = LocalDate(year = year, month = month, day = day)
79+
localDate.equals(null) shouldBe false
80+
}
81+
}
82+
83+
@Test
84+
fun `equals() should return false when invoked with a different type`() = runTest {
85+
val others = Arb.of("foo", 42, java.time.LocalDate.now())
86+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), others) { year, month, day, other ->
87+
val localDate = LocalDate(year = year, month = month, day = day)
88+
localDate.equals(other) shouldBe false
89+
}
90+
}
91+
92+
@Test
93+
fun `equals() should return false when only the year differs`() = runTest {
94+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int()) { year1, month, day, year2
95+
->
96+
assume(year1 != year2)
97+
val localDate1 = LocalDate(year = year1, month = month, day = day)
98+
val localDate2 = LocalDate(year = year2, month = month, day = day)
99+
localDate1.equals(localDate2) shouldBe false
100+
}
101+
}
102+
103+
@Test
104+
fun `equals() should return false when only the month differs`() = runTest {
105+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int()) { year, month1, day, month2
106+
->
107+
assume(month1 != month2)
108+
val localDate1 = LocalDate(year = year, month = month1, day = day)
109+
val localDate2 = LocalDate(year = year, month = month2, day = day)
110+
localDate1.equals(localDate2) shouldBe false
111+
}
112+
}
113+
114+
@Test
115+
fun `equals() should return false when only the day differs`() = runTest {
116+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int()) { year, month, day1, day2
117+
->
118+
assume(day1 != day2)
119+
val localDate1 = LocalDate(year = year, month = month, day = day1)
120+
val localDate2 = LocalDate(year = year, month = month, day = day2)
121+
localDate1.equals(localDate2) shouldBe false
122+
}
123+
}
124+
125+
@Test
126+
fun `hashCode() should return the same value when invoked repeatedly`() = runTest {
127+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
128+
val localDate = LocalDate(year = year, month = month, day = day)
129+
val hashCode = localDate.hashCode()
130+
repeat(5) { withClue("iteration=$it") { localDate.hashCode() shouldBe hashCode } }
131+
}
132+
}
133+
134+
@Test
135+
fun `hashCode() should return the same value when invoked on equal, but distinct, objects`() =
136+
runTest {
137+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
138+
val localDate1 = LocalDate(year = year, month = month, day = day)
139+
val localDate2 = LocalDate(year = year, month = month, day = day)
140+
localDate1.hashCode() shouldBe localDate2.hashCode()
141+
}
142+
}
143+
144+
@Test
145+
fun `hashCode() should return different values for different years`() = runTest {
146+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int()) { year1, month, day, year2
147+
->
148+
assume(year1.hashCode() != year2.hashCode())
149+
val localDate1 = LocalDate(year = year1, month = month, day = day)
150+
val localDate2 = LocalDate(year = year2, month = month, day = day)
151+
localDate1.hashCode() shouldNotBe localDate2.hashCode()
152+
}
153+
}
154+
155+
@Test
156+
fun `hashCode() should return different values for different months`() = runTest {
157+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int()) { year, month1, day, month2
158+
->
159+
assume(month1.hashCode() != month2.hashCode())
160+
val localDate1 = LocalDate(year = year, month = month1, day = day)
161+
val localDate2 = LocalDate(year = year, month = month2, day = day)
162+
localDate1.hashCode() shouldNotBe localDate2.hashCode()
163+
}
164+
}
165+
166+
@Test
167+
fun `hashCode() should return different values for different days`() = runTest {
168+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int()) { year, month, day1, day2
169+
->
170+
assume(day1.hashCode() != day2.hashCode())
171+
val localDate1 = LocalDate(year = year, month = month, day = day1)
172+
val localDate2 = LocalDate(year = year, month = month, day = day2)
173+
localDate1.hashCode() shouldNotBe localDate2.hashCode()
174+
}
175+
}
176+
177+
@Test
178+
fun `toString() should return a string conforming to what is expected`() = runTest {
179+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
180+
val localDate = LocalDate(year = year, month = month, day = day)
181+
val toStringResult = localDate.toString()
182+
assertSoftly {
183+
toStringResult shouldStartWith "LocalDate("
184+
toStringResult shouldEndWith ")"
185+
toStringResult shouldContainWithNonAbuttingText "year=$year"
186+
toStringResult shouldContainWithNonAbuttingText "month=$month"
187+
toStringResult shouldContainWithNonAbuttingText "day=$day"
188+
}
189+
}
190+
}
191+
192+
@Test
193+
fun `copy() with no arguments should return an equal, but distinct, instance`() = runTest {
194+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int()) { year, month, day ->
195+
val localDate1 = LocalDate(year = year, month = month, day = day)
196+
val localDate2 = localDate1.copy()
197+
localDate1 shouldBe localDate2
198+
}
199+
}
200+
201+
@Test
202+
fun `copy() with all arguments should return a new instance with the given arguments`() =
203+
runTest {
204+
checkAll(propTestConfig, Arb.int(), Arb.int(), Arb.int(), Arb.int(), Arb.int(), Arb.int()) {
205+
year1,
206+
month1,
207+
day1,
208+
year2,
209+
month2,
210+
day2 ->
211+
val localDate1 = LocalDate(year = year1, month = month1, day = day1)
212+
val localDate2 = localDate1.copy(year = year2, month = month2, day = day2)
213+
localDate2 shouldBe LocalDate(year = year2, month = month2, day = day2)
214+
}
215+
}
216+
217+
@Test
218+
fun `toJavaLocalDate() should return an equivalent java time LocalDate object`() = runTest {
219+
checkAll(propTestConfig, Arb.threeTenBp.localDate()) { testData ->
220+
val fdcLocalDate: LocalDate = testData.toDataConnectLocalDate()
221+
val jLocalDate: java.time.LocalDate = fdcLocalDate.toJavaLocalDate()
222+
assertSoftly {
223+
withClue("year") { jLocalDate.year shouldBe testData.year }
224+
withClue("month") { jLocalDate.month.number shouldBe testData.monthValue }
225+
withClue("dayOfMonth") { jLocalDate.dayOfMonth shouldBe testData.dayOfMonth }
226+
}
227+
}
228+
}
229+
230+
@Test
231+
fun `toKotlinxLocalDate() should return an equivalent java time LocalDate object`() = runTest {
232+
checkAll(propTestConfig, Arb.threeTenBp.localDate()) { testData ->
233+
val fdcLocalDate: LocalDate = testData.toDataConnectLocalDate()
234+
val kLocalDate: kotlinx.datetime.LocalDate = fdcLocalDate.toKotlinxLocalDate()
235+
assertSoftly {
236+
withClue("year") { kLocalDate.year shouldBe testData.year }
237+
withClue("month") { kLocalDate.month.number shouldBe testData.monthValue }
238+
withClue("dayOfMonth") { kLocalDate.dayOfMonth shouldBe testData.dayOfMonth }
239+
}
240+
}
241+
}
242+
243+
@Test
244+
fun `toDataConnectLocalDate() on java time LocalDate should return an equivalent LocalDate object`() =
245+
runTest {
246+
checkAll(propTestConfig, Arb.threeTenBp.localDate()) { testData ->
247+
val jLocalDate: java.time.LocalDate = testData.toJavaTimeLocalDate()
248+
val fdcLocalDate: LocalDate = jLocalDate.toDataConnectLocalDate()
249+
assertSoftly {
250+
withClue("year") { fdcLocalDate.year shouldBe testData.year }
251+
withClue("month") { fdcLocalDate.month shouldBe testData.monthValue }
252+
withClue("day") { fdcLocalDate.day shouldBe testData.dayOfMonth }
253+
}
254+
}
255+
}
256+
257+
@Test
258+
fun `toDataConnectLocalDate() on kotlinx datetime LocalDate should return an equivalent LocalDate object`() =
259+
runTest {
260+
checkAll(propTestConfig, Arb.threeTenBp.localDate()) { testData ->
261+
val kLocalDate: kotlinx.datetime.LocalDate = testData.toKotlinxDatetimeLocalDate()
262+
val fdcLocalDate: LocalDate = kLocalDate.toDataConnectLocalDate()
263+
assertSoftly {
264+
withClue("year") { fdcLocalDate.year shouldBe testData.year }
265+
withClue("month") { fdcLocalDate.month shouldBe testData.monthValue }
266+
withClue("day") { fdcLocalDate.day shouldBe testData.dayOfMonth }
267+
}
268+
}
269+
}
270+
271+
private companion object {
272+
val propTestConfig = PropTestConfig(iterations = 50)
273+
}
274+
}

0 commit comments

Comments
 (0)