Tester les pourcentages iOS par la méthode des plus forts restes
Une page de statistiques iOS affiche 33 % pour chacun de trois poids identiques, mais le total n’atteint que 99 %. Reporter tout l’écart sur le dernier élément risque, à l’inverse, d’attribuer un pourcentage à un élément de poids nul. Dans un pipeline de build sur Mac cloud, inutile de lancer le simulateur pour vérifier ce comportement : il suffit d’isoler l’algorithme de répartition dans une fonction Swift pure pour contrôler la somme, l’ordre et les entrées invalides, puis de faire utiliser ce même résultat par l’interface.
Définir d’abord l’unité d’affichage et le contrat d’entrée
On traite ici des comptages entiers non négatifs, par exemple le nombre de tâches terminées dans trois catégories, et non des montants, des mesures en virgule flottante ou des pourcentages déjà arrondis. Avec un total de 100 unités d’affichage, chaque unité représente un point de pourcentage. Pour afficher une décimale, on passe à 1000 unités, chacune représentant un dixième de point de pourcentage.
Le contrat comporte quatre règles : la sortie a la même longueur que l’entrée ; ses valeurs sont non négatives et leur somme est égale au nombre total d’unités ; un poids nul reçoit zéro unité ; une même entrée produit toujours le même résultat. Un tableau vide, un poids négatif ou une somme des poids nulle provoquent un échec explicite, sans générer arbitrairement une répartition uniforme.
« Aucune donnée » et « toutes les catégories ont la même part » sont deux états métier distincts. Lorsque tous les poids sont nuls, la page doit afficher un état vide : l’algorithme de répartition ne doit pas transformer cette absence de données en statistiques apparemment valides.
Les égalités de reste sont départagées par l’indice dans le tableau d’entrée. L’appelant doit donc classer les données au préalable selon un identifiant de catégorie stable. L’ordre de parcours d’un dictionnaire ne doit pas devenir une règle produit : sinon, un rafraîchissement pourrait changer les éléments qui reçoivent une unité de compensation.
Combler l’écart avec la méthode entière des plus forts restes
On commence par multiplier chaque poids par le nombre total d’unités, puis on divise le produit par la somme des poids. Le quotient donne le quota de base ; le reste détermine l’ordre d’attribution des unités manquantes. L’écart entre la somme des quotas de base et le total est comblé en ajoutant une unité par élément, par ordre décroissant des restes.
Comme chaque quotient est arrondi à l’entier inférieur, le nombre d’unités à distribuer est strictement inférieur au nombre d’éléments. Il n’est donc nécessaire ni de boucler jusqu’à convergence ni de prévoir une tolérance pour les calculs en virgule flottante. Enregistrez le code suivant dans percentage-check.swift : il contient à la fois l’implémentation et les vérifications minimales.
enum AllocationError: Error {
case invalidInput
case overflow
}
func allocate(_ weights: [Int], units: Int = 100) throws -> [Int] {
guard !weights.isEmpty,
(1...10_000).contains(units),
weights.allSatisfy({ $0 >= 0 }) else {
throw AllocationError.invalidInput
}
var total = 0
for weight in weights {
let sum = total.addingReportingOverflow(weight)
guard !sum.overflow else {
throw AllocationError.overflow
}
total = sum.partialValue
}
guard total > 0 else {
throw AllocationError.invalidInput
}
var result = [Int]()
var remainders = [Int]()
for weight in weights {
let product = weight.multipliedReportingOverflow(by: units)
guard !product.overflow else {
throw AllocationError.overflow
}
result.append(product.partialValue / total)
remainders.append(product.partialValue % total)
}
let remaining = units - result.reduce(0, +)
let order = weights.indices.sorted {
if remainders[$0] == remainders[$1] {
return $0 < $1
}
return remainders[$0] > remainders[$1]
}
for index in order.prefix(remaining) {
result[index] += 1
}
return result
}
func expectFailure(_ weights: [Int], units: Int = 100) {
do {
_ = try allocate(weights, units: units)
} catch {
return
}
preconditionFailure("Expected allocation to fail")
}
let equal = try allocate([1, 1, 1])
precondition(equal == [34, 33, 33])
let withZero = try allocate([0, 1, 1])
precondition(withZero == [0, 50, 50])
let tenths = try allocate([1, 1, 1], units: 1000)
precondition(tenths == [334, 333, 333])
for a in 0...12 {
for b in 0...12 {
for c in 0...12 {
let weights = [a, b, c]
let total = weights.reduce(0, +)
if total == 0 { continue }
let output = try allocate(weights)
precondition(output.count == weights.count)
precondition(output.reduce(0, +) == 100)
precondition(output.allSatisfy { $0 >= 0 })
for index in weights.indices {
let floorQuota = weights[index] * 100 / total
precondition(
output[index] == floorQuota ||
output[index] == floorQuota + 1
)
if weights[index] == 0 {
precondition(output[index] == 0)
}
}
}
}
}
expectFailure([])
expectFailure([0, 0])
expectFailure([-1, 2])
expectFailure([1], units: 0)
expectFailure([Int.max, 1])
expectFailure([Int.max])
Le code vérifie volontairement les dépassements de capacité à deux endroits : lors de l’addition et lors de la multiplication. Même si le pourcentage final est faible, le produit intermédiaire d’un poids peut dépasser la plage des entiers. Ici, on choisit de rejeter l’entrée plutôt que de masquer un changement de précision par une conversion en virgule flottante. Pour accepter des comptages plus élevés, il faut concevoir séparément des calculs sur des entiers de plus grande capacité.
Exécuter une vérification autonome sur Mac cloud
Vérifiez d’abord la chaîne d’outils active avec xcrun swift --version, puis exécutez les commandes suivantes. Si les vérifications réussissent, aucune sortie n’est produite. Une assertion non satisfaite ou une erreur non interceptée fait échouer le processus ; le pipeline doit impérativement conserver ce code de sortie.
set -eu
xcrun swift percentage-check.swift
Pour en faire un contrôle bloquant, vous pouvez compiler avec xcrun swiftc -Onone, puis exécuter le programme. N’utilisez pas de mode d’optimisation qui désactive ces assertions. À long terme, la solution la plus robuste consiste à placer la fonction dans le module métier, à déplacer les assertions dans une cible de tests et à les exécuter avec la commande de test existante. On évite ainsi de maintenir deux versions de l’algorithme, l’une dans le script et l’autre dans le produit.
L’énumération porte ici sur toutes les combinaisons de trois poids dans une petite plage de valeurs ; il ne s’agit pas d’un benchmark. Elle couvre les cas proches du tout-zéro, ceux où un seul élément reçoit la totalité, les restes égaux et les petits poids arrondis à zéro. Pour enrichir les tests, diversifiez les formes d’entrée plutôt que de répéter simplement le même échantillon uniforme.
Distinguer les erreurs d’arrondi des règles métier
Le piège le plus facile à manquer est la perte de correspondance avec les éléments après le tri. L’implémentation ne trie que les indices : le tableau final correspond toujours aux éléments d’origine. L’interface ne doit donc pas utiliser directement le classement des restes comme ordre d’affichage des catégories.
Ensuite, n’exigez pas que « chaque poids strictement positif reçoive au moins une unité ». Lorsque le nombre de catégories dépasse le nombre d’unités d’affichage, cette exigence est incompatible avec la conservation de la somme. Pour rendre visibles des parts très faibles, vous pouvez augmenter la précision d’affichage ou prévoir une mention « inférieur au seuil d’affichage », mais pas modifier discrètement les quotas.
Enfin, cette méthode ne garantit pas la monotonie d’un jeu de données à l’autre. Le classement des restes peut changer si l’on modifie les poids d’autres catégories ou la précision d’affichage. Les tests doivent vérifier les bornes des quotas pour les données courantes, et non affirmer que la part d’un élément ne diminuera jamais au fil des rafraîchissements.
Le formatage décimal ne sert qu’à l’affichage
Avec un total de 1000 unités, 334 représente 33,4 %. Si l’API de formatage attend une proportion comprise entre zéro et un, transmettez la valeur correspondant à 334 / 1000, sans la multiplier encore par cent. L’étape de formatage ne doit ni recalculer les parts à partir des poids d’origine, ni effectuer un nouvel arrondi à une autre précision.
Transformer les échecs en éléments vérifiables
Avant la fusion, conservez les poids d’entrée, le nombre total d’unités, les quotas de base, le classement des restes et la sortie finale. Si les statistiques de production peuvent contenir des catégories sensibles, reproduisez le cas avec des jeux de données de test fixes, sans écrire les détails métier réels dans les artefacts de build.
La revue doit porter sur trois points : les entrées invalides échouent-elles explicitement ? Les égalités de reste sont-elles départagées de façon stable ? Le formatage de l’interface conserve-t-il la précision définie ? Une fois la validation acquise, la couche de données ne fournit que les poids, la couche de répartition ne fournit que les unités entières et la couche d’affichage ne s’occupe que du formatage localisé. Cette séparation des responsabilités permet de rattacher toute anomalie de somme à une étape précise, plutôt que de préserver une apparence de justesse en reportant l’écart sur le dernier élément.
Questions fréquentes
Pourquoi ne pas arrondir chaque pourcentage séparément ?
Les arrondis indépendants ne préservent pas le total. Trois poids égaux affichés à 33 % donnent 99 %. La méthode attribue d'abord les quotas entiers, puis distribue les unités restantes selon les restes.
Une hausse du poids garantit-elle une hausse du pourcentage affiché ?
Pas entre des jeux de données différents. Une modification des autres poids ou du nombre d'unités peut changer le classement des restes. Les garanties portent ici sur le total, les bornes des quotas et le déterminisme.
Validez votre pipeline sur un Mac dans le cloud
ArmRDP met à votre disposition des Mac mini physiques dédiés, et non des machines virtuelles. Louez à la journée : vérifiez d’abord votre chaîne d’outils et les résultats de compilation, puis choisissez la durée adaptée à votre équipe.
Louer un Mac dans le cloud