Skip to content

Commit 565cfd0

Browse files
malysclaude
andcommitted
fix(chargepilot): a percent sign the driver reads, not the one the formatter escapes
The battery calibration hint read "Charging to 100 %%", and the charge-model notice "at least 2 %%", because both strings escaped their percent for a format call that never happens: they are rendered by plain getString(id), which hands back the two characters as written. A single % is the literal, with formatted="false" so lint does not read it as a conversion. StringPercentTest fails on any %% left in a string with no positional argument, in both locales, so the next one is caught before it ships. Gradle cannot see resources as an input of a JVM test, so the test task declared res/ and the seeded fixtures itself — without that the guard stays UP-TO-DATE and a changed string passes the check unread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9d74791 commit 565cfd0

4 files changed

Lines changed: 52 additions & 4 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ tasks.register("printUnstableVersion") {
115115
}
116116
}
117117

118+
// Some unit tests read files off disk: res/values*/strings.xml, because the app's own resources
119+
// are not on a plain JVM test's classpath and nothing here runs Robolectric, and the seeded
120+
// fixtures, which live beside the mise task that pushes them. Gradle cannot see those inputs by
121+
// itself, so a changed string or fixture would leave the tests UP-TO-DATE and the check would
122+
// pass without running them. Declared here rather than worked around in the tests.
123+
tasks.withType<Test>().configureEach {
124+
inputs.dir(layout.projectDirectory.dir("src/main/res"))
125+
.withPropertyName("appResources")
126+
.withPathSensitivity(PathSensitivity.RELATIVE)
127+
inputs.files(rootProject.layout.projectDirectory.files(
128+
"evchargepilot-trips.json",
129+
"evchargepilot-battery-ledger.json",
130+
)).withPropertyName("seededFixtures").withPathSensitivity(PathSensitivity.RELATIVE)
131+
}
132+
118133
dependencies {
119134
implementation(project(":evhardware"))
120135
implementation(libs.androidx.core.ktx)

‎app/src/main/res/values-fr/strings.xml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@
191191
<string name="energy_breakdown_charge_verdict_climate">Les portions parcourues climatisation en marche ont coûté plus de charge que vos trajets habituels.</string>
192192
<string name="energy_breakdown_charge_verdict_other">Un résiduel se distingue, mais les preuves disponibles ne permettent pas de l\'attribuer à un consommateur précis.</string>
193193
<string name="energy_breakdown_charge_rate">Sur ce trajet, 1 %% de charge a couvert environ %1$.1f km. La charge hors portions modélisées reste sur sa propre ligne.</string>
194-
<string name="energy_breakdown_charge_untrained">Pas encore assez de conduite enregistrée pour ajuster le modèle de charge de cette voiture : il faut douze portions d\'au moins 2 %% et 2 km, sur une plage de vitesses et de températures. Aucune estimation n\'est affichée à la place.</string>
194+
<string name="energy_breakdown_charge_untrained" formatted="false">Pas encore assez de conduite enregistrée pour ajuster le modèle de charge de cette voiture : il faut douze portions d\'au moins 2 % et 2 km, sur une plage de vitesses et de températures. Aucune estimation n\'est affichée à la place.</string>
195195
<string name="energy_breakdown_charge_no_interval">Ce trajet ne comporte aucune portion avec une vitesse, une température extérieure et une lecture de charge dont le modèle puisse parler.</string>
196196
<string name="energy_breakdown_charge_too_small">La jauge n\'a pas bougé de plus de deux crans sur ce trajet. Répartir cela entre conduite et reste reviendrait à répartir la résolution de la jauge.</string>
197197
<string name="action_speed_what_if">Comparer les vitesses</string>
@@ -464,7 +464,7 @@ Consommation %6$s %% par km</string>
464464
<string name="battery_health_declared">Votre valeur déclarée : %1$.0f %% de %2$.1f kWh.</string>
465465
<string name="battery_health_calibration_settled">Stable. La jauge a vu une charge complète il y a %1$.0f jours, et c’est là-dessus qu’elle se recale.</string>
466466
<string name="battery_health_calibration_watch">À surveiller. Dernière charge complète il y a %1$.0f jours.</string>
467-
<string name="battery_health_calibration_re_anchor">Une charge complète aiderait. Charger à 100 %% et laisser la voiture branchée jusqu’à l’arrêt du courant est ce qui permet au pack de recaler son estimation de charge.</string>
467+
<string name="battery_health_calibration_re_anchor" formatted="false">Une charge complète aiderait. Charger à 100 % et laisser la voiture branchée jusqu’à l’arrêt du courant est ce qui permet au pack de recaler son estimation de charge.</string>
468468
<string name="battery_health_calibration_never_full">Cette application n’a jamais vu cette voiture chargée au-dessus de %1$.0f %%.</string>
469469
<string name="battery_health_calibration_steps">%1$d saut(s) de charge que rien n’explique, le plus grand de %2$.0f points à l’arrêt. C’est la jauge qui se réestime, pas le pack qui se vide.</string>
470470
<string name="battery_health_calibration_cycles">%1$.1f cycles complets équivalents depuis.</string>

‎app/src/main/res/values/strings.xml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@
195195
<string name="energy_breakdown_charge_verdict_climate">The stretches driven with the climate on cost more charge than your usual trips do.</string>
196196
<string name="energy_breakdown_charge_verdict_other">A residual is distinguishable, but the available evidence cannot assign it to a separate consumer.</string>
197197
<string name="energy_breakdown_charge_rate">On this trip 1 %% of charge ran about %1$.1f km. Charge outside the modelled stretches stays in its own row.</string>
198-
<string name="energy_breakdown_charge_untrained">Not enough recorded driving yet to fit this car\'s charge model: it needs twelve stretches of at least 2 %% and 2 km, across a range of speeds and temperatures. Nothing is estimated in its place.</string>
198+
<string name="energy_breakdown_charge_untrained" formatted="false">Not enough recorded driving yet to fit this car\'s charge model: it needs twelve stretches of at least 2 % and 2 km, across a range of speeds and temperatures. Nothing is estimated in its place.</string>
199199
<string name="energy_breakdown_charge_no_interval">This trip has no stretch with a speed, an outside temperature and a charge reading the model can speak about.</string>
200200
<string name="energy_breakdown_charge_too_small">The gauge moved two steps or less across this trip. Splitting that between driving and everything else would be splitting the gauge\'s own resolution.</string>
201201
<string name="action_speed_what_if">Speed comparison</string>
@@ -473,7 +473,7 @@ Spending %6$s %% per km</string>
473473
<string name="battery_health_declared">Your declared figure: %1$.0f %% of %2$.1f kWh.</string>
474474
<string name="battery_health_calibration_settled">Settled. The gauge saw a full charge %1$.0f days ago, which is what it corrects itself against.</string>
475475
<string name="battery_health_calibration_watch">Worth watching. The last full charge was %1$.0f days ago.</string>
476-
<string name="battery_health_calibration_re_anchor">A full charge would help. Charging to 100 %% and leaving the car plugged in until it stops drawing is what lets the pack re-anchor its own charge estimate.</string>
476+
<string name="battery_health_calibration_re_anchor" formatted="false">A full charge would help. Charging to 100 % and leaving the car plugged in until it stops drawing is what lets the pack re-anchor its own charge estimate.</string>
477477
<string name="battery_health_calibration_never_full">This app has never seen this car charged above %1$.0f %%.</string>
478478
<string name="battery_health_calibration_steps">%1$d charge step(s) nothing explains, the largest %2$.0f points while parked. That is the gauge re-estimating, not the pack losing charge.</string>
479479
<string name="battery_health_calibration_cycles">%1$.1f equivalent full cycles since then.</string>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.evsuite.chargepilot
2+
3+
import java.io.File
4+
import javax.xml.parsers.DocumentBuilderFactory
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Test
7+
8+
/**
9+
* `%%` is an escape, and only a formatted string is ever unescaped: `getString(id)` hands back the
10+
* two characters as they are, which is how the battery page came to offer "Charging to 100 %%".
11+
* A string carrying `%%` and no `%n$` argument is therefore always one of two bugs — a doubled
12+
* sign shown to the driver, or an argument that was meant to be there and is not. Both locales are
13+
* checked, because a translation is where the escape survives longest.
14+
*/
15+
class StringPercentTest {
16+
@Test
17+
fun `an escaped percent only appears in a string that is formatted`() {
18+
val offenders = LOCALES.flatMap { locale ->
19+
val file = File("src/main/res/$locale/strings.xml")
20+
val root = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
21+
val strings = root.getElementsByTagName("string")
22+
(0 until strings.length).map { strings.item(it) }
23+
.filter { it.textContent.contains("%%") && !ARGUMENT.containsMatchIn(it.textContent) }
24+
.map { "$locale/${it.attributes.getNamedItem("name").nodeValue}" }
25+
}
26+
assertEquals(emptyList<String>(), offenders)
27+
}
28+
29+
private companion object {
30+
val LOCALES = listOf("values", "values-fr")
31+
val ARGUMENT = Regex("""%\d+\$""")
32+
}
33+
}

0 commit comments

Comments
 (0)