erplibre/test/test_check_stale_scss.py
Mathieu Benoit 4bc82b4caa [ADD] migration: predict which customized SCSS the next bump breaks
Customized SCSS lives in ir_attachment, frozen the day it is written,
still using the variables of that version. A bump can rename them:
website declared $o-theme-font-number in 12.0 and replaced the whole
mechanism in 13.0, so a 2020 customization stopped the frontend
bundle. Same shape as a stale COW view.

Reading the stored SCSS against the target sources answers before the
bump and names the attachment. Booting Odoo and opening a page answers
after, with « Style error » and no name — on a page a broken frontend
bundle is exactly what keeps you from reaching.

Measured: on the pre-bump database, targeting 13.0, it predicts the
failure; against its own 12.0 sources it reports nothing. That noise
floor is what makes it usable — the first version cried wolf on six
mixin parameters and named @include arguments.

--- FR ---

[ADD] migration : prédire quel SCSS personnalisé le palier va casser

Un SCSS personnalisé vit dans ir_attachment, figé le jour où il est
écrit, employant encore les variables de cette version-là. Un palier
peut les renommer : website déclarait $o-theme-font-number en 12.0 et a
remplacé le mécanisme en 13.0, arrêtant le bundle sur une
personnalisation de 2020. Même forme qu'une vue COW périmée.

Lire le SCSS stocké contre les sources cibles répond AVANT le palier et
nomme la pièce jointe. Démarrer Odoo et ouvrir une page répond après,
par « Style error » sans nom — et un bundle cassé est justement ce qui
empêche d'atteindre la page.

Mesuré : sur la base d'avant le palier, cible 13.0, il prédit la
panne ; contre ses propres sources 12.0 il ne signale rien. Ce bruit de
fond nul est ce qui le rend utilisable — la première version criait à
tort sur six paramètres de mixin et arguments nommés.

Assisted-by: Claude Opus 5
(cherry picked from commit c6611ee1431cfee94e321536d20052b8cca3ec10)
2026-08-22 07:23:59 -04:00

160 lines
6 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un SCSS personnalisé est une copie figée, comme une vue COW.
Personnaliser un site écrit du SCSS dans `ir_attachment`. Cette copie est
figée le jour où elle est écrite et continue d'employer les variables de
CETTE version. Un module peut les renommer d'un palier à l'autre : mesuré,
`website/.../primary_variables.scss` déclarait `$o-theme-font-number` en 12.0
et a remplacé tout le mécanisme en 13.0. Une personnalisation de 2020
demandait encore l'ancien nom, et le bundle s'arrêtait dessus.
Ce qui rend un tel détecteur utilisable n'est pas de trouver — c'est de ne
pas crier à tort. Sur le seul fichier mesuré, la première version rapportait
dix noms dont six n'étaient pas des manques : paramètres de mixin, variables
de boucle, arguments nommés d'`@include`. Ces tests portent surtout là-dessus.
"""
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
import check_stale_scss as scss # noqa: E402
class TestWhatCountsAsAUse(unittest.TestCase):
def test_a_plain_dereference_is_a_use(self):
self.assertIn("color", scss.used_names("a { b: $color; }"))
def test_a_declaration_is_not_a_use(self):
self.assertNotIn("color", scss.used_names("$color: red;"))
def test_a_named_include_argument_is_not_a_use(self):
# LE faux positif mesuré : « @include o-position-absolute(
# $right: 50%) » se lisait comme l'usage d'un $right inexistant.
source = "@include o-position-absolute($right: 50%, $left: 50%);"
self.assertEqual(scss.used_names(source), set())
def test_a_default_value_is_not_a_use(self):
self.assertNotIn("size", scss.used_names("$size: 3 !default;"))
def test_the_name_is_not_truncated_to_dodge_the_colon(self):
# Une négation en tête de motif fait rétrograder le nom : le premier
# essai rapportait « $botto » au lieu d'écarter « $bottom ».
names = scss.used_names("a { b: $bottom-margin; }")
self.assertEqual(names, {"bottom-margin"})
def test_a_use_after_a_declaration_still_counts(self):
self.assertEqual(scss.used_names("$a: $b;"), {"b"})
class TestWhatTheFileBindsItself(unittest.TestCase):
def test_mixin_parameters_are_bound(self):
source = "@mixin thing($on, $off: 2) { a: $on; b: $off; }"
self.assertEqual(scss.bound_names(source), {"on", "off"})
def test_function_parameters_are_bound(self):
source = "@function f($value) { @return $value; }"
self.assertIn("value", scss.bound_names(source))
def test_each_loop_variables_are_bound(self):
source = "@each $key, $val in $map { a: $key; }"
bound = scss.bound_names(source)
self.assertIn("key", bound)
self.assertIn("val", bound)
def test_for_loop_variables_are_bound(self):
source = "@for $counter from 1 through 3 { a: $counter; }"
self.assertIn("counter", scss.bound_names(source))
class TestTheUrlIsSplitForTheFix(unittest.TestCase):
"""reset_asset veut le fichier ET le bundle ; l'URL porte les deux."""
def test_a_frontend_customization(self):
base, bundle = scss.split_custom_url(
"/website/static/src/scss/website.custom.web.assets_frontend.scss"
)
self.assertEqual(base, "/website/static/src/scss/website.scss")
self.assertEqual(bundle, "web.assets_frontend")
def test_a_common_customization(self):
base, bundle = scss.split_custom_url(
"/website/static/src/scss/options/colors/"
"user_theme_color_palette.custom.web.assets_common.scss"
)
self.assertTrue(base.endswith("user_theme_color_palette.scss"))
self.assertEqual(bundle, "web.assets_common")
class TestTheReport(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_nothing_at_risk_says_so(self):
text = scss.render([], "db", "odoo13.0")
self.assertIn("✅", text)
def test_a_finding_names_the_attachment_and_the_variable(self):
text = scss.render(
[
(
1110,
"/a/b.custom.web.assets_frontend.scss",
["o-theme-font-number"],
)
],
"db",
"odoo13.0",
)
self.assertIn("1110", text)
self.assertIn("$o-theme-font-number", text)
def test_it_prints_a_command_that_can_be_pasted(self):
# Un diagnostic sans le geste qui répare oblige à rechercher les deux
# arguments de reset_asset au pire moment.
text = scss.render(
[(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])],
"mydb",
"odoo13.0",
)
self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text)
self.assertIn("-d mydb", text)
def test_it_warns_before_dropping(self):
text = scss.render(
[(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])],
"db",
"odoo13.0",
)
self.assertIn("real customization", text)
class TestTheMigrationRunsIt(unittest.TestCase):
def test_it_is_checked_at_the_same_place_as_the_cow_views(self):
# Les deux prédictions valent avant le palier, pas après : c'est le
# seul moment où l'on peut encore arbitrer sans avoir tout rejoué.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertIn("check_stale_scss.py", source)
self.assertLess(
source.index("check_stale_scss.py"),
source.index("4 - Upgrade version with OpenUpgrade"),
)
if __name__ == "__main__":
unittest.main()