[layerindex-web][PATCH 4/5] tests: add DuplicatesView tests
Tim Orling <[email protected]>
| Newsgroups | org.yoctoproject.lists.yocto-patches |
|---|---|
| Message-ID | <[email protected]> |
Add test_duplicates_view.py covering the DuplicatesView duplicates page: - HTTP 200 response - Duplicate recipes/classes/include files are included in context - Unique (non-duplicate) objects are excluded from context - Each duplicate appears once per layer (two rows for two layers) - Layer filter (?l=id) restricts results to the selected layer, suppressing duplicates when only one layer is in scope - Branch with no data returns empty querysets - select_related check: accessing layerbranch.layer.name after list evaluation fires no additional queries [YOCTO #16175] AI-Generated: Claude Cowork Sonnet 4.6 Signed-off-by: Tim Orling <[email protected]> --- tests/test_duplicates_view.py | 218 ++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/test_duplicates_view.py diff --git a/tests/test_duplicates_view.py b/tests/test_duplicates_view.py new file mode 100644 index 0000000..304a35c --- /dev/null +++ b/tests/test_duplicates_view.py @@ -0,0 +1,218 @@ +# layerindex-web - tests for DuplicatesView +# +# Copyright (C) 2026 Tim Orling <[email protected]> +# +# Licensed under the MIT license, see COPYING.MIT for details +# +# SPDX-License-Identifier: MIT + +# Tests for bug #16175 - Duplicates page timeout +# https://bugzilla.yoctoproject.org/show_bug.cgi?id=16175 +# +# The fix replaces Python list comprehensions used to build IN (...) clauses +# with Django ORM subqueries, and adds select_related to avoid N+1 queries +# when the template resolves layerbranch__layer names. + +import pytest +from django.test import TestCase +from django.urls import reverse + +from layerindex.models import (Branch, LayerItem, LayerBranch, Recipe, + BBClass, IncFile) + + [email protected]_db +class TestDuplicatesView(TestCase): + """Tests for DuplicatesView (bug #16175 - duplicates page timeout fix).""" + + def setUp(self): + self.branch = Branch.objects.create( + name='main', + bitbake_branch='master', + short_description='Main branch', + sort_priority=1, + hidden=False, + updates_enabled=True, + ) + + # Two layers — objects in both will appear as duplicates + self.layer_a = LayerItem.objects.create( + name='meta-alpha', + status='P', + layer_type='A', + summary='Alpha layer', + description='Alpha test layer', + vcs_url='git://example.com/meta-alpha.git', + ) + self.layer_b = LayerItem.objects.create( + name='meta-beta', + status='P', + layer_type='S', + summary='Beta layer', + description='Beta test layer', + vcs_url='git://example.com/meta-beta.git', + ) + # A third layer whose objects are unique (should NOT appear as duplicates) + self.layer_c = LayerItem.objects.create( + name='meta-gamma', + status='P', + layer_type='S', + summary='Gamma layer', + description='Gamma test layer', + vcs_url='git://example.com/meta-gamma.git', + ) + + self.lb_a = LayerBranch.objects.create(layer=self.layer_a, branch=self.branch) + self.lb_b = LayerBranch.objects.create(layer=self.layer_b, branch=self.branch) + self.lb_c = LayerBranch.objects.create(layer=self.layer_c, branch=self.branch) + + # Duplicate recipe (same pn in layer_a and layer_b) + Recipe.objects.create(layerbranch=self.lb_a, filename='shared_1.0.bb', + pn='shared', pv='1.0', filepath='recipes-test') + Recipe.objects.create(layerbranch=self.lb_b, filename='shared_1.0.bb', + pn='shared', pv='1.0', filepath='recipes-test') + # Unique recipe (only in layer_c — should NOT appear) + Recipe.objects.create(layerbranch=self.lb_c, filename='unique_1.0.bb', + pn='unique', pv='1.0', filepath='recipes-test') + + # Duplicate class (same name in layer_a and layer_b) + BBClass.objects.create(layerbranch=self.lb_a, name='sharedclass') + BBClass.objects.create(layerbranch=self.lb_b, name='sharedclass') + # Unique class (only in layer_c — should NOT appear) + BBClass.objects.create(layerbranch=self.lb_c, name='uniqueclass') + + # Duplicate include file (same path in layer_a and layer_b) + IncFile.objects.create(layerbranch=self.lb_a, path='conf/shared.inc') + IncFile.objects.create(layerbranch=self.lb_b, path='conf/shared.inc') + # Unique include file (only in layer_c — should NOT appear) + IncFile.objects.create(layerbranch=self.lb_c, path='conf/unique.inc') + + def _url(self, branch='main'): + return reverse('duplicates', kwargs={'branch': branch}) + + def test_duplicates_view_returns_200(self): + """DuplicatesView should return HTTP 200.""" + response = self.client.get(self._url()) + self.assertEqual(response.status_code, 200) + + # --- Recipes --- + + def test_duplicate_recipes_included(self): + """Recipes appearing in more than one layer should be in context.""" + response = self.client.get(self._url()) + pns = [r.pn for r in response.context['recipes']] + self.assertIn('shared', pns) + + def test_unique_recipes_excluded(self): + """Recipes appearing in only one layer should not appear.""" + response = self.client.get(self._url()) + pns = [r.pn for r in response.context['recipes']] + self.assertNotIn('unique', pns) + + def test_duplicate_recipes_have_two_rows(self): + """Each layer's entry for the duplicate recipe should be present.""" + response = self.client.get(self._url()) + shared = [r for r in response.context['recipes'] if r.pn == 'shared'] + self.assertEqual(len(shared), 2) + + # --- Classes --- + + def test_duplicate_classes_included(self): + """Classes appearing in more than one layer should be in context.""" + response = self.client.get(self._url()) + names = [c.name for c in response.context['classes']] + self.assertIn('sharedclass', names) + + def test_unique_classes_excluded(self): + """Classes appearing in only one layer should not appear.""" + response = self.client.get(self._url()) + names = [c.name for c in response.context['classes']] + self.assertNotIn('uniqueclass', names) + + def test_duplicate_classes_have_two_rows(self): + """Both layer entries for the duplicate class should be present.""" + response = self.client.get(self._url()) + shared = [c for c in response.context['classes'] if c.name == 'sharedclass'] + self.assertEqual(len(shared), 2) + + # --- Include files --- + + def test_duplicate_incfiles_included(self): + """Include files appearing in more than one layer should be in context.""" + response = self.client.get(self._url()) + paths = [f.path for f in response.context['incfiles']] + self.assertIn('conf/shared.inc', paths) + + def test_unique_incfiles_excluded(self): + """Include files appearing in only one layer should not appear.""" + response = self.client.get(self._url()) + paths = [f.path for f in response.context['incfiles']] + self.assertNotIn('conf/unique.inc', paths) + + def test_duplicate_incfiles_have_two_rows(self): + """Both layer entries for the duplicate include file should be present.""" + response = self.client.get(self._url()) + shared = [f for f in response.context['incfiles'] if f.path == 'conf/shared.inc'] + self.assertEqual(len(shared), 2) + + # --- Layer filter --- + + def test_layer_filter_restricts_recipes(self): + """Passing ?l=<layer_id> should restrict results to that layer.""" + response = self.client.get(self._url() + f'?l={self.layer_a.id}') + self.assertEqual(response.status_code, 200) + # With only one layer selected, nothing can be a duplicate + pns = [r.pn for r in response.context['recipes']] + self.assertNotIn('shared', pns) + + def test_wrong_branch_returns_empty(self): + """A branch with no data should return empty querysets.""" + other = Branch.objects.create( + name='other', + bitbake_branch='other', + sort_priority=50, + ) + response = self.client.get(reverse('duplicates', kwargs={'branch': 'other'})) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(list(response.context['recipes'])), 0) + self.assertEqual(len(list(response.context['classes'])), 0) + self.assertEqual(len(list(response.context['incfiles'])), 0) + + def test_select_related_avoids_extra_queries(self): + """Ensure layerbranch and layer are fetched with select_related. + + Checks that accessing layerbranch.layer.name on query results does not + trigger additional database queries (i.e. select_related is working). + """ + from django.db import connection, reset_queries + from django.conf import settings + + settings.DEBUG = True + reset_queries() + + view = __import__('layerindex.views', fromlist=['DuplicatesView']).DuplicatesView + v = view() + v.kwargs = {'branch': 'main'} + v.request = None + + recipes = list(v.get_recipes([])) + classes = list(v.get_classes([])) + incfiles = list(v.get_incfiles([])) + + # Record query count after fetching + query_count_after_fetch = len(connection.queries) + + # Access layerbranch.layer.name on every result — should NOT fire new queries + for r in recipes: + _ = r.layerbranch.layer.name + for c in classes: + _ = c.layerbranch.layer.name + for f in incfiles: + _ = f.layerbranch.layer.name + + query_count_after_access = len(connection.queries) + settings.DEBUG = False + + self.assertEqual(query_count_after_fetch, query_count_after_access, + "Unexpected extra queries when accessing layerbranch.layer.name — " + "select_related may not be working") -- 2.54.0