addons-cm/website_sale_lazy_loading/tests/test_lazy_loading.py
GitHub Copilot a9d6f52ca6 [ADD] website_sale_lazy_loading: load the /shop listing on demand
The shop pages products: every page is a full reload that throws away the
grid the visitor was reading. This appends the next page to the grid
instead, on scroll or on a "Load more products" click, per website.

The controller does not duplicate /shop. The new /shop/lazy_products calls
shop() and renders the cards out of the qcontext it prepared, so search,
categories, attributes, tags, price filter, sort order and pricelists are
supported by construction -- and so are the values other modules add to the
listing, the wishlist state among them. Out of range pages answer empty
rather than the last page again, which portal.pager would otherwise clamp
to and the frontend would append as duplicates.

The product loop of website_sale.products is replaced by a call to a shared
template, so the first page and the appended ones are the same markup: a
ribbon, a price or a button another module adds to products_item shows up
on every card, not only on the ones the initial render produced.

Progressive enhancement throughout: the first page and the pager are still
what the standard controller renders, and the pager is only hidden once the
widget is running. Without JavaScript -- and for crawlers -- the shop is
exactly what it is without this module. The page size is the shop layout's
"Products per page", the value the pager already uses, so there is nothing
to keep in sync.

The frontend takes no decision about what to show: the server sends the
mode, the URL of the listing on screen and each page of cards. It observes
a block below the grid rather than listening to scroll, restarts the public
widgets on the appended cards, and keeps a button as the fallback for a
failed request or a browser without IntersectionObserver.

Tests cover the block rendering per mode, the filters travelling in the
AJAX URL, and the endpoint on a next page, the last page, an out of range
page, a broken page number and an unknown category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:33:09 +02:00

144 lines
6.1 KiB
Python

# Copyright 2026 - Today Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo.tests import tagged
from odoo.tests.common import HttpCase
PER_PAGE = 3
PRODUCT_COUNT = 7
PAGE_COUNT = 3 # ceil(7 / 3)
@tagged("post_install", "-at_install")
class TestLazyLoading(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.website = cls.env["website"].browse(1)
cls.website.shop_ppg = PER_PAGE
# The listing has to be predictable: only the products created here may
# show up in the shop.
cls.env["product.template"].search([("is_published", "=", True)]).write(
{"is_published": False}
)
cls.category = cls.env["product.public.category"].create({"name": "Lazy Cat"})
cls.other_category = cls.env["product.public.category"].create(
{"name": "Other Cat"}
)
cls.products = cls.env["product.template"].create(
[
{
"name": "Lazy Product %02d" % index,
"list_price": 10.0 + index,
"is_published": True,
"public_categ_ids": [(6, 0, cls.category.ids)],
}
for index in range(PRODUCT_COUNT)
]
)
cls.other_product = cls.env["product.template"].create(
{
"name": "Other Product",
"list_price": 5.0,
"is_published": True,
"public_categ_ids": [(6, 0, cls.other_category.ids)],
}
)
def _get_lazy_page(self, page, **params):
"""Call the AJAX endpoint and return its decoded payload."""
query = "&".join("%s=%s" % (key, value) for key, value in params.items())
url = "/shop/lazy_products?page=%s" % page
if query:
url = "%s&%s" % (url, query)
response = self.url_open(url)
self.assertEqual(response.status_code, 200)
return response.json()
# ------------------------------------------------------------------
# /shop rendering
# ------------------------------------------------------------------
def test_shop_renders_lazy_loading_block(self):
"""The listing carries the block driving the lazy loading."""
self.website.shop_lazy_loading = "scroll"
body = self.url_open("/shop").text
self.assertIn("o_wsale_lazy_loading", body)
self.assertIn('data-mode="scroll"', body)
self.assertIn('data-page-count="%s"' % PAGE_COUNT, body)
# The pager is still served: it is the fallback without JavaScript.
self.assertIn("products_pager", body)
def test_shop_without_lazy_loading(self):
"""The standard pager is left alone when the mode is off."""
self.website.shop_lazy_loading = "off"
body = self.url_open("/shop").text
self.assertNotIn("o_wsale_lazy_loading", body)
self.assertIn("products_pager", body)
def test_shop_last_page_has_no_block(self):
"""Nothing can be lazy loaded from the last page."""
self.website.shop_lazy_loading = "scroll"
body = self.url_open("/shop/page/%s" % PAGE_COUNT).text
self.assertNotIn("o_wsale_lazy_loading", body)
def test_lazy_loading_url_keeps_the_filters(self):
"""The AJAX URL carries the filters of the listing on screen."""
self.website.shop_lazy_loading = "scroll"
body = self.url_open("/shop?search=Lazy&order=name+desc").text
self.assertIn("/shop/lazy_products?", body)
self.assertIn("search=Lazy", body)
self.assertIn("order=name+desc", body)
# ------------------------------------------------------------------
# /shop/lazy_products
# ------------------------------------------------------------------
def test_lazy_page_returns_the_next_products(self):
"""A page holds the products of that page, and only those."""
result = self._get_lazy_page(2)
self.assertEqual(result["page"], 2)
self.assertEqual(result["page_count"], PAGE_COUNT)
self.assertTrue(result["has_next"])
self.assertIn("oe_product", result["html"])
for product in self.products[PER_PAGE : PER_PAGE * 2]:
self.assertIn(product.name, result["html"])
for product in self.products[:PER_PAGE]:
self.assertNotIn(product.name, result["html"])
def test_lazy_page_last_page(self):
"""The last page reports that there is nothing left to load."""
result = self._get_lazy_page(PAGE_COUNT)
self.assertFalse(result["has_next"])
self.assertIn(self.products[-1].name, result["html"])
def test_lazy_page_out_of_range(self):
"""An out of range page answers empty instead of repeating the last."""
result = self._get_lazy_page(PAGE_COUNT + 5)
self.assertEqual(result["html"], "")
self.assertFalse(result["has_next"])
def test_lazy_page_invalid_page(self):
"""A broken page number falls back to the first page."""
result = self._get_lazy_page("not-a-page")
self.assertEqual(result["page"], 1)
self.assertIn(self.products[0].name, result["html"])
def test_lazy_page_keeps_the_category(self):
"""Filtering by category is kept across the appended pages."""
result = self._get_lazy_page(2, category=self.category.id)
self.assertIn(self.products[PER_PAGE].name, result["html"])
self.assertNotIn(self.other_product.name, result["html"])
def test_lazy_page_ignores_a_broken_category(self):
"""A category that is not an id is dropped instead of crashing."""
result = self._get_lazy_page(1, category="pwned")
self.assertEqual(result["page"], 1)
self.assertIn(self.products[0].name, result["html"])
def test_lazy_page_unknown_category(self):
"""An unknown category answers a clean 404, not a traceback."""
unknown_id = self.other_category.id + 1000
response = self.url_open("/shop/lazy_products?page=1&category=%s" % unknown_id)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["error"], "not_found")