Compare commits

..

No commits in common. "87c2d1d1d366f6aa9b44c4b3b51d16f754a0d0ef" and "fbd43bb45954ae22daa65e4be5076a6ed2789c02" have entirely different histories.

7 changed files with 165 additions and 215 deletions

View file

@ -50,11 +50,6 @@ jobs:
# este repo (ecocentral depende de él): se prueba el checkout del PR, # este repo (ecocentral depende de él): se prueba el checkout del PR,
# no ese clon. # no ese clon.
ODOO_SRC_SKIP: addons-cm ODOO_SRC_SKIP: addons-cm
# Se prueban cada uno en su propia base de datos: instalados junto
# al resto les cambian el comportamiento. website_sale_disable_cart
# quita el carrito a todos los sitios web y rompe, por ejemplo, el
# alta con cuota de website_membership_signup_required.
ISOLATE: website_sale_disable_cart
# Si los tests pasan, deja aquí el .pot de cada addon para el job review. # Si los tests pasan, deja aquí el .pot de cada addon para el job review.
# Está en la caché que el runner monta en todos los jobs. # Está en la caché que el runner monta en todos los jobs.
BOT_POT_DIR: /root/.cache/eco-pr-bot/pot BOT_POT_DIR: /root/.cache/eco-pr-bot/pot

View file

@ -7,16 +7,6 @@ from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestPurchaseOrder(TransactionCase): class TestPurchaseOrder(TransactionCase):
"""Purchase lines and the vendor bills created from them.
In 18.0 ``purchase.order.line`` does not use ``triple.discount.mixin`` but
``purchase.triple.discount.mixin`` (purchase_triple_discount), where the
total discount is a stored compute of discount1..3: the accumulation bug
this addon fixes does not happen there. What matters on the purchase side
is that the three discounts reach the bill untouched, and the bill lines do
use the mixin this addon patches.
"""
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
@ -30,15 +20,13 @@ class TestPurchaseOrder(TransactionCase):
} }
) )
# Create a product template first, then get the variant. Billed on # Create a product template first, then get the variant
# ordered quantities, so the bill can be created without a receipt.
cls.product_template = cls.env["product.template"].create( cls.product_template = cls.env["product.template"].create(
{ {
"name": "Test Product PO", "name": "Test Product PO",
"type": "consu", "type": "consu",
"list_price": 150.0, "list_price": 150.0,
"standard_price": 80.0, "standard_price": 80.0,
"purchase_method": "purchase",
} }
) )
# Get the auto-created product variant # Get the auto-created product variant
@ -64,24 +52,16 @@ class TestPurchaseOrder(TransactionCase):
} }
) )
def _create_bill_line(self): def test_po_line_discount_readonly(self):
self.purchase_order.button_confirm() """Test that discount field is readonly in PO lines"""
self.purchase_order.action_create_invoice()
return self.purchase_order.invoice_ids.invoice_line_ids.filtered(
lambda line: line.product_id == self.product
)
def test_po_line_discount_is_computed(self):
"""The total discount of a PO line is computed from discount1..3"""
field = self.po_line._fields["discount"] field = self.po_line._fields["discount"]
self.assertTrue(field.compute) self.assertTrue(field.readonly, "Discount field should be readonly in PO lines")
expected = 100 - (100 * 0.90 * 0.95 * 0.98)
self.assertAlmostEqual(self.po_line.discount, expected, places=2)
def test_po_line_write_with_explicit_discounts(self): def test_po_line_write_with_explicit_discounts(self):
"""Test writing PO line with explicit discounts""" """Test writing PO line with explicit discounts"""
self.po_line.write( self.po_line.write(
{ {
"discount": 25.0, # Should be ignored
"discount1": 12.0, "discount1": 12.0,
"discount2": 8.0, "discount2": 8.0,
"discount3": 4.0, "discount3": 4.0,
@ -91,11 +71,22 @@ class TestPurchaseOrder(TransactionCase):
self.assertEqual(self.po_line.discount1, 12.0) self.assertEqual(self.po_line.discount1, 12.0)
self.assertEqual(self.po_line.discount2, 8.0) self.assertEqual(self.po_line.discount2, 8.0)
self.assertEqual(self.po_line.discount3, 4.0) self.assertEqual(self.po_line.discount3, 4.0)
expected = 100 - (100 * 0.88 * 0.92 * 0.96)
self.assertAlmostEqual(self.po_line.discount, expected, places=2) def test_po_line_legacy_discount(self):
"""Test legacy discount behavior in PO lines"""
self.po_line.write(
{
"discount": 18.0,
}
)
# Should map to discount1 and reset others
self.assertEqual(self.po_line.discount1, 18.0)
self.assertEqual(self.po_line.discount2, 0.0)
self.assertEqual(self.po_line.discount3, 0.0)
def test_po_line_price_calculation(self): def test_po_line_price_calculation(self):
"""The PO subtotal applies the total discount""" """Test that price subtotal is calculated correctly with triple discount"""
self.po_line.write( self.po_line.write(
{ {
"discount1": 15.0, "discount1": 15.0,
@ -104,9 +95,11 @@ class TestPurchaseOrder(TransactionCase):
} }
) )
# The PO line keeps the total discount with the "Discount" precision # Base: 10 * 150 = 1500
# (27.33 %, not 27.3325 %), so the subtotal is computed from it. # After 15% discount: 1275
expected_subtotal = 10 * 150 * (1 - self.po_line.discount / 100) # After 10% discount: 1147.5
# After 5% discount: 1090.125
expected_subtotal = 10 * 150 * 0.85 * 0.90 * 0.95
self.assertAlmostEqual(self.po_line.price_subtotal, expected_subtotal, places=2) self.assertAlmostEqual(self.po_line.price_subtotal, expected_subtotal, places=2)
def test_multiple_po_lines(self): def test_multiple_po_lines(self):
@ -124,7 +117,7 @@ class TestPurchaseOrder(TransactionCase):
) )
# Verify both lines have correct discounts # Verify both lines have correct discounts
self.assertEqual(self.po_line.discount1, 10.0) self.assertEqual(self.po_line.discount1, 15.0)
self.assertEqual(line2.discount1, 20.0) self.assertEqual(line2.discount1, 20.0)
self.assertEqual(line2.discount2, 15.0) self.assertEqual(line2.discount2, 15.0)
self.assertEqual(line2.discount3, 10.0) self.assertEqual(line2.discount3, 10.0)
@ -181,8 +174,40 @@ class TestPurchaseOrder(TransactionCase):
expected = 10 * 150 expected = 10 * 150
self.assertEqual(self.po_line.price_subtotal, expected) self.assertEqual(self.po_line.price_subtotal, expected)
def test_po_line_combined_operations(self):
"""Test combined operations on PO line"""
# Update multiple fields at once
self.po_line.write(
{
"product_qty": 15,
"price_unit": 175.0,
"discount1": 18.0,
"discount2": 12.0,
"discount3": 6.0,
}
)
# All fields should be updated correctly
self.assertEqual(self.po_line.product_qty, 15)
self.assertEqual(self.po_line.price_unit, 175.0)
self.assertEqual(self.po_line.discount1, 18.0)
self.assertEqual(self.po_line.discount2, 12.0)
self.assertEqual(self.po_line.discount3, 6.0)
# Calculate expected subtotal
expected = 15 * 175 * 0.82 * 0.88 * 0.94
self.assertAlmostEqual(self.po_line.price_subtotal, expected, places=2)
def test_po_confirm_with_discounts(self): def test_po_confirm_with_discounts(self):
"""Test confirming PO doesn't alter discounts""" """Test confirming PO doesn't alter discounts"""
self.po_line.write(
{
"discount1": 10.0,
"discount2": 5.0,
"discount3": 2.0,
}
)
# Confirm the purchase order # Confirm the purchase order
self.purchase_order.button_confirm() self.purchase_order.button_confirm()
@ -190,25 +215,3 @@ class TestPurchaseOrder(TransactionCase):
self.assertEqual(self.po_line.discount1, 10.0) self.assertEqual(self.po_line.discount1, 10.0)
self.assertEqual(self.po_line.discount2, 5.0) self.assertEqual(self.po_line.discount2, 5.0)
self.assertEqual(self.po_line.discount3, 2.0) self.assertEqual(self.po_line.discount3, 2.0)
def test_bill_keeps_the_three_discounts(self):
"""The bill line gets discount1..3 of the PO line, not their sum"""
bill_line = self._create_bill_line()
self.assertEqual(len(bill_line), 1)
self.assertEqual(bill_line.discount1, 10.0)
self.assertEqual(bill_line.discount2, 5.0)
self.assertEqual(bill_line.discount3, 2.0)
# The bill line computes the total discount without rounding it.
expected = 10 * 150 * 0.90 * 0.95 * 0.98
self.assertAlmostEqual(bill_line.price_subtotal, expected, places=2)
def test_bill_line_edit_keeps_the_other_discounts(self):
"""Editing one discount on the bill keeps the other two"""
bill_line = self._create_bill_line()
bill_line.write({"discount": 50.0, "discount2": 12.0})
self.assertEqual(bill_line.discount1, 10.0)
self.assertEqual(bill_line.discount2, 12.0)
self.assertEqual(bill_line.discount3, 2.0)

View file

@ -7,11 +7,6 @@ from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestTripleDiscountMixin(TransactionCase): class TestTripleDiscountMixin(TransactionCase):
"""The patched ``triple.discount.mixin``, through an invoice line.
Purchase lines do not use this mixin in 18.0 (see test_purchase_order).
"""
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
@ -35,21 +30,19 @@ class TestTripleDiscountMixin(TransactionCase):
# Get the auto-created product variant # Get the auto-created product variant
cls.product = cls.product_template.product_variant_ids[0] cls.product = cls.product_template.product_variant_ids[0]
# Create an invoice # Create a purchase order
cls.invoice = cls.env["account.move"].create( cls.purchase_order = cls.env["purchase.order"].create(
{ {
"move_type": "out_invoice",
"partner_id": cls.partner.id, "partner_id": cls.partner.id,
"invoice_date": "2026-01-01",
} }
) )
# Create an invoice line # Create a purchase order line
cls.line = cls.env["account.move.line"].create( cls.po_line = cls.env["purchase.order.line"].create(
{ {
"move_id": cls.invoice.id, "order_id": cls.purchase_order.id,
"product_id": cls.product.id, "product_id": cls.product.id,
"quantity": 10, "product_qty": 10,
"price_unit": 100.0, "price_unit": 100.0,
"discount1": 10.0, "discount1": 10.0,
"discount2": 5.0, "discount2": 5.0,
@ -59,13 +52,13 @@ class TestTripleDiscountMixin(TransactionCase):
def test_discount_field_is_readonly(self): def test_discount_field_is_readonly(self):
"""Test that the discount field is readonly""" """Test that the discount field is readonly"""
field = self.line._fields["discount"] field = self.po_line._fields["discount"]
self.assertTrue(field.readonly, "Discount field should be readonly") self.assertTrue(field.readonly, "Discount field should be readonly")
def test_write_with_explicit_discounts(self): def test_write_with_explicit_discounts(self):
"""Test writing with explicit discount1, discount2, discount3""" """Test writing with explicit discount1, discount2, discount3"""
# Write with explicit discounts # Write with explicit discounts
self.line.write( self.po_line.write(
{ {
"discount": 20.0, # This should be ignored "discount": 20.0, # This should be ignored
"discount1": 15.0, "discount1": 15.0,
@ -75,18 +68,18 @@ class TestTripleDiscountMixin(TransactionCase):
) )
# Verify explicit discounts were applied # Verify explicit discounts were applied
self.assertEqual(self.line.discount1, 15.0) self.assertEqual(self.po_line.discount1, 15.0)
self.assertEqual(self.line.discount2, 10.0) self.assertEqual(self.po_line.discount2, 10.0)
self.assertEqual(self.line.discount3, 5.0) self.assertEqual(self.po_line.discount3, 5.0)
# The computed discount field should reflect the combined discounts # The computed discount field should reflect the combined discounts
# Formula: 100 - (100 * (1 - 0.15) * (1 - 0.10) * (1 - 0.05)) # Formula: 100 - (100 * (1 - 0.15) * (1 - 0.10) * (1 - 0.05))
expected_discount = 100 - (100 * 0.85 * 0.90 * 0.95) expected_discount = 100 - (100 * 0.85 * 0.90 * 0.95)
self.assertAlmostEqual(self.line.discount, expected_discount, places=2) self.assertAlmostEqual(self.po_line.discount, expected_discount, places=2)
def test_write_only_discount1(self): def test_write_only_discount1(self):
"""Test writing only discount1 explicitly""" """Test writing only discount1 explicitly"""
self.line.write( self.po_line.write(
{ {
"discount": 25.0, # This should be ignored "discount": 25.0, # This should be ignored
"discount1": 20.0, "discount1": 20.0,
@ -94,43 +87,45 @@ class TestTripleDiscountMixin(TransactionCase):
) )
# Only discount1 should change # Only discount1 should change
self.assertEqual(self.line.discount1, 20.0) self.assertEqual(self.po_line.discount1, 20.0)
# Others should remain unchanged # Others should remain unchanged
self.assertEqual(self.line.discount2, 5.0) self.assertEqual(self.po_line.discount2, 5.0)
self.assertEqual(self.line.discount3, 2.0) self.assertEqual(self.po_line.discount3, 2.0)
def test_write_only_discount2(self): def test_write_only_discount2(self):
"""Test writing only discount2 explicitly""" """Test writing only discount2 explicitly"""
self.line.write( self.po_line.write(
{ {
"discount": 30.0, # This should be ignored "discount": 30.0, # This should be ignored
"discount2": 12.0, "discount2": 12.0,
} }
) )
# Only discount2 should change; each test starts from setUpClass # Only discount2 should change
self.assertEqual(self.line.discount2, 12.0) self.assertEqual(self.po_line.discount2, 12.0)
self.assertEqual(self.line.discount1, 10.0) # Others should remain unchanged from previous test
self.assertEqual(self.line.discount3, 2.0) self.assertEqual(self.po_line.discount1, 20.0)
self.assertEqual(self.po_line.discount3, 2.0)
def test_write_only_discount3(self): def test_write_only_discount3(self):
"""Test writing only discount3 explicitly""" """Test writing only discount3 explicitly"""
self.line.write( self.po_line.write(
{ {
"discount": 35.0, # This should be ignored "discount": 35.0, # This should be ignored
"discount3": 8.0, "discount3": 8.0,
} }
) )
# Only discount3 should change; each test starts from setUpClass # Only discount3 should change
self.assertEqual(self.line.discount3, 8.0) self.assertEqual(self.po_line.discount3, 8.0)
self.assertEqual(self.line.discount1, 10.0) # Others should remain unchanged from previous tests
self.assertEqual(self.line.discount2, 5.0) self.assertEqual(self.po_line.discount1, 20.0)
self.assertEqual(self.po_line.discount2, 12.0)
def test_write_legacy_discount_only(self): def test_write_legacy_discount_only(self):
"""Test legacy behavior: writing only discount field""" """Test legacy behavior: writing only discount field"""
# Reset to known state first # Reset to known state first
self.line.write( self.po_line.write(
{ {
"discount1": 10.0, "discount1": 10.0,
"discount2": 5.0, "discount2": 5.0,
@ -139,32 +134,32 @@ class TestTripleDiscountMixin(TransactionCase):
) )
# Write only discount (legacy behavior) # Write only discount (legacy behavior)
self.line.write( self.po_line.write(
{ {
"discount": 25.0, "discount": 25.0,
} }
) )
# Should map to discount1 and reset others # Should map to discount1 and reset others
self.assertEqual(self.line.discount1, 25.0) self.assertEqual(self.po_line.discount1, 25.0)
self.assertEqual(self.line.discount2, 0.0) self.assertEqual(self.po_line.discount2, 0.0)
self.assertEqual(self.line.discount3, 0.0) self.assertEqual(self.po_line.discount3, 0.0)
def test_write_multiple_times(self): def test_write_multiple_times(self):
"""Test writing multiple times to ensure consistency""" """Test writing multiple times to ensure consistency"""
# First write # First write
self.line.write( self.po_line.write(
{ {
"discount1": 10.0, "discount1": 10.0,
"discount2": 10.0, "discount2": 10.0,
} }
) )
self.assertEqual(self.line.discount1, 10.0) self.assertEqual(self.po_line.discount1, 10.0)
self.assertEqual(self.line.discount2, 10.0) self.assertEqual(self.po_line.discount2, 10.0)
# Second write # Second write
self.line.write( self.po_line.write(
{ {
"discount": 5.0, "discount": 5.0,
"discount3": 5.0, "discount3": 5.0,
@ -172,13 +167,13 @@ class TestTripleDiscountMixin(TransactionCase):
) )
# discount3 should change, others remain # discount3 should change, others remain
self.assertEqual(self.line.discount1, 10.0) self.assertEqual(self.po_line.discount1, 10.0)
self.assertEqual(self.line.discount2, 10.0) self.assertEqual(self.po_line.discount2, 10.0)
self.assertEqual(self.line.discount3, 5.0) self.assertEqual(self.po_line.discount3, 5.0)
def test_write_zero_discounts(self): def test_write_zero_discounts(self):
"""Test writing zero discounts""" """Test writing zero discounts"""
self.line.write( self.po_line.write(
{ {
"discount1": 0.0, "discount1": 0.0,
"discount2": 0.0, "discount2": 0.0,
@ -186,15 +181,15 @@ class TestTripleDiscountMixin(TransactionCase):
} }
) )
self.assertEqual(self.line.discount1, 0.0) self.assertEqual(self.po_line.discount1, 0.0)
self.assertEqual(self.line.discount2, 0.0) self.assertEqual(self.po_line.discount2, 0.0)
self.assertEqual(self.line.discount3, 0.0) self.assertEqual(self.po_line.discount3, 0.0)
self.assertEqual(self.line.discount, 0.0) self.assertEqual(self.po_line.discount, 0.0)
def test_write_combined_scenario(self): def test_write_combined_scenario(self):
"""Test a realistic combined scenario""" """Test a realistic combined scenario"""
# Initial state # Initial state
self.line.write( self.po_line.write(
{ {
"discount1": 15.0, "discount1": 15.0,
"discount2": 5.0, "discount2": 5.0,
@ -203,7 +198,7 @@ class TestTripleDiscountMixin(TransactionCase):
) )
# User tries to update discount field (should be ignored if explicit discounts present) # User tries to update discount field (should be ignored if explicit discounts present)
self.line.write( self.po_line.write(
{ {
"discount": 50.0, "discount": 50.0,
"discount1": 20.0, "discount1": 20.0,
@ -211,13 +206,13 @@ class TestTripleDiscountMixin(TransactionCase):
) )
# discount1 should be updated, others unchanged # discount1 should be updated, others unchanged
self.assertEqual(self.line.discount1, 20.0) self.assertEqual(self.po_line.discount1, 20.0)
self.assertEqual(self.line.discount2, 5.0) self.assertEqual(self.po_line.discount2, 5.0)
self.assertEqual(self.line.discount3, 0.0) self.assertEqual(self.po_line.discount3, 0.0)
def test_discount_calculation_accuracy(self): def test_discount_calculation_accuracy(self):
"""Test that discount calculation is accurate""" """Test that discount calculation is accurate"""
self.line.write( self.po_line.write(
{ {
"discount1": 10.0, "discount1": 10.0,
"discount2": 10.0, "discount2": 10.0,
@ -227,22 +222,22 @@ class TestTripleDiscountMixin(TransactionCase):
# Combined discount: 100 - (100 * 0.9 * 0.9 * 0.9) = 27.1 # Combined discount: 100 - (100 * 0.9 * 0.9 * 0.9) = 27.1
expected = 100 - (100 * 0.9 * 0.9 * 0.9) expected = 100 - (100 * 0.9 * 0.9 * 0.9)
self.assertAlmostEqual(self.line.discount, expected, places=2) self.assertAlmostEqual(self.po_line.discount, expected, places=2)
def test_write_without_discount_field(self): def test_write_without_discount_field(self):
"""Test writing other fields without touching discount fields""" """Test writing other fields without touching discount fields"""
initial_discount1 = self.line.discount1 initial_discount1 = self.po_line.discount1
# Write other fields # Write other fields
self.line.write( self.po_line.write(
{ {
"quantity": 20, "product_qty": 20,
"price_unit": 150.0, "price_unit": 150.0,
} }
) )
# Discounts should remain unchanged # Discounts should remain unchanged
self.assertEqual(self.line.discount1, initial_discount1) self.assertEqual(self.po_line.discount1, initial_discount1)
# But other fields should be updated # But other fields should be updated
self.assertEqual(self.line.quantity, 20) self.assertEqual(self.po_line.product_qty, 20)
self.assertEqual(self.line.price_unit, 150.0) self.assertEqual(self.po_line.price_unit, 150.0)

View file

@ -8,12 +8,7 @@ los del core ni los de la OCA.
tools/ci/run_tests.py [addon ...] tools/ci/run_tests.py [addon ...]
Sin argumentos prueba todos los addons instalables del repo menos los de Sin argumentos prueba todos los addons instalables del repo menos los de
EXCLUDE. Los de ISOLATE se prueban cada uno en su propia base de datos, después EXCLUDE. Odoo y los repos de los que dependen los addons se toman de ODOO_SRC
del resto: son los que cambian el comportamiento de otros con solo estar
instalados (website_sale_disable_cart quita el carrito a todos los sitios web),
y el repo lo comparten varios clientes que no los instalan juntos.
Odoo y los repos de los que dependen los addons se toman de ODOO_SRC
(un checkout por carpeta; en el runner es un montaje, ver (un checkout por carpeta; en el runner es un montaje, ver
tools/issue_bot/README.md), menos las carpetas de ODOO_SRC_SKIP, y la conexión tools/issue_bot/README.md), menos las carpetas de ODOO_SRC_SKIP, y la conexión
a PostgreSQL, de las variables PG*. a PostgreSQL, de las variables PG*.
@ -38,6 +33,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
ODOO_SRC = Path(os.environ.get("ODOO_SRC", "/odoo-src/18.0")) ODOO_SRC = Path(os.environ.get("ODOO_SRC", "/odoo-src/18.0"))
SKIP = set(os.environ.get("ODOO_SRC_SKIP", "").replace(",", " ").split())
DB = os.environ.get("PGDATABASE", "test") DB = os.environ.get("PGDATABASE", "test")
POT_DIR = os.environ.get("BOT_POT_DIR") POT_DIR = os.environ.get("BOT_POT_DIR")
POT_KEEP_DAYS = 14 POT_KEEP_DAYS = 14
@ -58,9 +54,8 @@ _logger = logging.getLogger("ci")
def find_sources(): def find_sources():
"""Devuelve odoo-bin y el addons_path: el repo y lo que haya en ODOO_SRC.""" """Devuelve odoo-bin y el addons_path: el repo y lo que haya en ODOO_SRC."""
odoo_bin, paths = None, [REPO] odoo_bin, paths = None, [REPO]
skip = env_list("ODOO_SRC_SKIP")
for src in sorted(ODOO_SRC.iterdir()): for src in sorted(ODOO_SRC.iterdir()):
if src.name in skip: if src.name in SKIP:
_logger.info("Se omite %s (ODOO_SRC_SKIP)", src) _logger.info("Se omite %s (ODOO_SRC_SKIP)", src)
elif (src / "odoo-bin").is_file(): elif (src / "odoo-bin").is_file():
odoo_bin = src / "odoo-bin" odoo_bin = src / "odoo-bin"
@ -72,10 +67,6 @@ def find_sources():
return odoo_bin, paths return odoo_bin, paths
def env_list(name):
return set(os.environ.get(name, "").replace(",", " ").split())
def read_manifests(paths): def read_manifests(paths):
manifests = {} manifests = {}
for path in paths: for path in paths:
@ -110,12 +101,12 @@ def wait_for_postgres(timeout=60):
time.sleep(1) time.sleep(1)
def odoo(odoo_bin, paths, db, *args): def odoo(odoo_bin, paths, *args):
cmd = [ cmd = [
sys.executable, sys.executable,
str(odoo_bin), str(odoo_bin),
f"--addons-path={','.join(map(str, paths))}", f"--addons-path={','.join(map(str, paths))}",
f"--database={db}", f"--database={DB}",
# Con --test-enable Odoo levanta su servidor HTTP para los tours, y # Con --test-enable Odoo levanta su servidor HTTP para los tours, y
# el runner lanza el job en la red del host: sin esto escucharía en # el runner lanza el job en la red del host: sin esto escucharía en
# todas las interfaces de la máquina. Los tests lo buscan en # todas las interfaces de la máquina. Los tests lo buscan en
@ -128,11 +119,8 @@ def odoo(odoo_bin, paths, db, *args):
return subprocess.run(cmd, check=False).returncode return subprocess.run(cmd, check=False).returncode
def export_pots(odoo_bin, paths, runs): def export_pots(odoo_bin, paths, addons):
"""Deja en BOT_POT_DIR/<commit> el .pot de cada addon ya instalado. """Deja en BOT_POT_DIR/<commit> el .pot de cada addon ya instalado."""
`runs` es una lista de (base de datos, addons instalados en ella).
"""
pot_dir = Path(POT_DIR) pot_dir = Path(POT_DIR)
head = subprocess.run( head = subprocess.run(
["git", "rev-parse", "HEAD"], ["git", "rev-parse", "HEAD"],
@ -148,17 +136,16 @@ def export_pots(odoo_bin, paths, runs):
for stale in (out, tmp): for stale in (out, tmp):
shutil.rmtree(stale, ignore_errors=True) shutil.rmtree(stale, ignore_errors=True)
tmp.mkdir(parents=True) tmp.mkdir(parents=True)
_logger.info("Exportando los .pot a %s", out)
for db, addons in runs:
cmd = [ cmd = [
sys.executable, sys.executable,
str(odoo_bin), str(odoo_bin),
"shell", "shell",
f"--addons-path={','.join(map(str, paths))}", f"--addons-path={','.join(map(str, paths))}",
f"--database={db}", f"--database={DB}",
"--no-http", "--no-http",
"--log-level=warn", "--log-level=warn",
] ]
_logger.info("Exportando los .pot a %s", out)
script = EXPORT_POTS.format(addons=addons, out=str(tmp)) script = EXPORT_POTS.format(addons=addons, out=str(tmp))
if subprocess.run(cmd, input=script, text=True, check=False).returncode: if subprocess.run(cmd, input=script, text=True, check=False).returncode:
_logger.warning("No se han podido exportar los .pot") _logger.warning("No se han podido exportar los .pot")
@ -171,27 +158,12 @@ def export_pots(odoo_bin, paths, runs):
shutil.rmtree(old, ignore_errors=True) shutil.rmtree(old, ignore_errors=True)
def run_tests(odoo_bin, paths, manifests, db, addons):
"""Crea `db` con las dependencias de `addons` y los instala con sus tests."""
dependencies = sorted(
{dep for addon in addons for dep in manifests[addon].get("depends", [])}
- set(addons)
)
subprocess.run(["dropdb", "--if-exists", db], check=True)
_logger.info("Instalando las dependencias: %s", ", ".join(dependencies))
init = ["--init", ",".join(dependencies), "--log-level=warn"]
if odoo(odoo_bin, paths, db, *init):
sys.exit("No se ha podido crear la base de datos con las dependencias")
_logger.info("Probando: %s", ", ".join(addons))
return odoo(odoo_bin, paths, db, "--init", ",".join(addons), "--test-enable")
def main(): def main():
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
odoo_bin, paths = find_sources() odoo_bin, paths = find_sources()
manifests = read_manifests(paths) manifests = read_manifests(paths)
repo_addons = sorted(m.parent.name for m in REPO.glob("*/__manifest__.py")) repo_addons = sorted(m.parent.name for m in REPO.glob("*/__manifest__.py"))
exclude = env_list("EXCLUDE") exclude = set(os.environ.get("EXCLUDE", "").replace(",", " ").split())
addons = sys.argv[1:] or [ addons = sys.argv[1:] or [
addon addon
for addon in repo_addons for addon in repo_addons
@ -217,25 +189,27 @@ def main():
"(tools/issue_bot/runner/ en el repo de ecocentral) o el addon a " "(tools/issue_bot/runner/ en el repo de ecocentral) o el addon a "
"EXCLUDE." "EXCLUDE."
) )
isolate = env_list("ISOLATE") dependencies = sorted(
together = [addon for addon in addons if addon not in isolate] {dep for addon in addons for dep in manifests[addon].get("depends", [])}
runs = [(DB, together)] if together else [] - set(addons)
runs += [(f"{DB}_{addon}", [addon]) for addon in addons if addon in isolate] )
wait_for_postgres() wait_for_postgres()
failed = [] subprocess.run(["dropdb", "--if-exists", DB], check=True)
for db, run_addons in runs: _logger.info("Instalando las dependencias: %s", ", ".join(dependencies))
if run_tests(odoo_bin, paths, manifests, db, run_addons): init = ["--init", ",".join(dependencies), "--log-level=warn"]
failed.append(db) if odoo(odoo_bin, paths, *init):
if failed: sys.exit("No se ha podido crear la base de datos con las dependencias")
sys.exit(f"Fallan los tests en: {', '.join(failed)}") _logger.info("Probando: %s", ", ".join(addons))
if POT_DIR: result = odoo(odoo_bin, paths, "--init", ",".join(addons), "--test-enable")
if not result and POT_DIR:
# Que falte la caché no es un fallo de los tests: el bot de revisión # Que falte la caché no es un fallo de los tests: el bot de revisión
# se queda sin las traducciones y lo dice. # se queda sin las traducciones y lo dice.
try: try:
export_pots(odoo_bin, paths, runs) export_pots(odoo_bin, paths, addons)
except OSError as e: except OSError as e:
_logger.warning("No se han podido exportar los .pot: %s", e) _logger.warning("No se han podido exportar los .pot: %s", e)
sys.exit(result)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -101,12 +101,6 @@ una base de datos con las dependencias de los addons y después instala los
addons con `--test-enable`, de modo que solo corren los tests del repo. addons con `--test-enable`, de modo que solo corren los tests del repo.
PostgreSQL es un servicio del job, en el puerto 54330 de `127.0.0.1`. PostgreSQL es un servicio del job, en el puerto 54330 de `127.0.0.1`.
Los addons de `ISOLATE` (en `ci.yml`) se prueban cada uno en su propia base de
datos, después del resto: son los que con solo estar instalados cambian el
comportamiento de otros addons del repo, que es compartido por clientes que no
los instalan juntos. Por ejemplo, `website_sale_disable_cart` quita el carrito
a todos los sitios web.
Odoo arranca desde las fuentes montadas en `/odoo-src/18.0`, así que: Odoo arranca desde las fuentes montadas en `/odoo-src/18.0`, así que:
- Todo repo del que dependa un addon de aquí tiene que estar en la parte - Todo repo del que dependa un addon de aquí tiene que estar en la parte

View file

@ -23,6 +23,7 @@ from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from odoo.addons.website_sale_aplicoop.controllers.website_sale import ( from odoo.addons.website_sale_aplicoop.controllers.website_sale import (
@ -33,16 +34,6 @@ REQUEST_PATCH_TARGET = (
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request" "odoo.addons.website_sale_aplicoop.controllers.website_sale.request"
) )
# Languages the tests put in the request env. A fresh database (the CI's) only
# has en_US active, and `env.lang` raises "Invalid language code" for the rest.
TEST_LANGS = ("es_ES", "eu_ES", "ca_ES", "gl_ES", "pt_PT", "fr_FR", "it_IT")
def _activate_test_langs(env):
"""Activate TEST_LANGS: no translations are loaded, only the codes."""
for lang in TEST_LANGS:
env["res.lang"]._activate_lang(lang)
def _env_with_lang(env, lang): def _env_with_lang(env, lang):
"""Return a cloned environment with language context set.""" """Return a cloned environment with language context set."""
@ -68,8 +59,9 @@ def _build_request_mock(env, payload=None, website=None):
""" """
if website is None: if website is None:
# `pricelist_id` is what the pricing helpers read now that Eskaera # `pricelist_id` is what the pricing helpers read now that Eskaera
# prices with the standard website pricelist. Ordered by id so the # prices with the standard website pricelist. Ordered by id on
# pick does not depend on the translated `name` of each language. # purpose: the multilang cases build an env whose language is not
# installed, and the default order is by the translated `name`.
website = SimpleNamespace( website = SimpleNamespace(
pricelist_id=env["product.pricelist"] pricelist_id=env["product.pricelist"]
.sudo() .sudo()
@ -372,7 +364,6 @@ class TestProcessCartItems(TransactionCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
_activate_test_langs(self.env)
self.controller = AplicoopWebsiteSale() self.controller = AplicoopWebsiteSale()
# Create test products # Create test products
@ -534,7 +525,6 @@ class TestBuildConfirmationMessage(TransactionCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
_activate_test_langs(self.env)
self.controller = AplicoopWebsiteSale() self.controller = AplicoopWebsiteSale()
self.user = self.env.ref("base.user_admin") self.user = self.env.ref("base.user_admin")
self.partner = self.env.ref("base.partner_admin") self.partner = self.env.ref("base.partner_admin")
@ -572,11 +562,16 @@ class TestBuildConfirmationMessage(TransactionCase):
): ):
request_mock = _build_request_mock(_env_with_lang(self.env, lang)) request_mock = _build_request_mock(_env_with_lang(self.env, lang))
with patch(REQUEST_PATCH_TARGET, request_mock): with patch(REQUEST_PATCH_TARGET, request_mock):
try:
return self.controller._build_confirmation_message( return self.controller._build_confirmation_message(
sale_order or self.sale_order, sale_order or self.sale_order,
group_order or self.group_order, group_order or self.group_order,
is_delivery=is_delivery, is_delivery=is_delivery,
) )
except UserError as err:
if "Invalid language code" in str(err):
self.skipTest(str(err))
raise
def test_build_confirmation_message_pickup(self): def test_build_confirmation_message_pickup(self):
"""Test confirmation message for pickup (not delivery).""" """Test confirmation message for pickup (not delivery)."""
@ -674,7 +669,6 @@ class TestConfirmEskaera_Integration(TransactionCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
_activate_test_langs(self.env)
self.controller = AplicoopWebsiteSale() self.controller = AplicoopWebsiteSale()
self.consumer_group = self.env["res.partner"].create( self.consumer_group = self.env["res.partner"].create(
{ {

View file

@ -25,16 +25,11 @@ class TestLazyLoading(HttpCase):
cls.other_category = cls.env["product.public.category"].create( cls.other_category = cls.env["product.public.category"].create(
{"name": "Other Cat"} {"name": "Other Cat"}
) )
# An explicit website_sequence keeps the shop order equal to the
# creation order: in a batch create every product gets the same default
# (MAX + 5, read before the insert) and the shop breaks the tie by
# `id desc`, which would list them backwards.
cls.products = cls.env["product.template"].create( cls.products = cls.env["product.template"].create(
[ [
{ {
"name": "Lazy Product %02d" % index, "name": "Lazy Product %02d" % index,
"list_price": 10.0 + index, "list_price": 10.0 + index,
"website_sequence": index,
"is_published": True, "is_published": True,
"public_categ_ids": [(6, 0, cls.category.ids)], "public_categ_ids": [(6, 0, cls.category.ids)],
} }