gentest.py (60795B)
1 """Generates Canvas tests from YAML file definitions. 2 3 See README.md for instructions on how to run this test generator and for how to 4 add or modify tests. 5 6 This code generatror was originally written by Philip Taylor for use at 7 http://philip.html5.org/tests/canvas/suite/tests/ 8 9 It has been adapted for use with the Web Platform Test Suite suite at 10 https://github.com/web-platform-tests/wpt/ 11 """ 12 13 from typing import Any, Callable, Container, DefaultDict, FrozenSet 14 from typing import List, Mapping, MutableMapping, Set, Tuple, Union 15 16 import re 17 import collections 18 import copy 19 import dataclasses 20 import enum 21 import importlib 22 import math 23 import os 24 import pathlib 25 import sys 26 import textwrap 27 28 import jinja2 29 30 try: 31 import cairocffi as cairo # type: ignore 32 except ImportError: 33 import cairo 34 35 try: 36 # Compatible and lots faster. 37 import syck as yaml # type: ignore 38 except ImportError: 39 import yaml 40 41 42 class Error(Exception): 43 """Base class for all exceptions raised by this module""" 44 45 46 class InvalidTestDefinitionError(Error): 47 """Raised on invalid test definition.""" 48 49 50 class _CanvasType(str, enum.Enum): 51 HTML_CANVAS = 'HtmlCanvas' 52 OFFSCREEN_CANVAS = 'OffscreenCanvas' 53 WORKER = 'Worker' 54 55 56 class _TemplateType(str, enum.Enum): 57 REFERENCE = 'reference' 58 HTML_REFERENCE = 'html_reference' 59 CAIRO_REFERENCE = 'cairo_reference' 60 IMG_REFERENCE = 'img_reference' 61 TESTHARNESS = 'testharness' 62 63 64 _REFERENCE_TEMPLATES = (_TemplateType.REFERENCE, 65 _TemplateType.HTML_REFERENCE, 66 _TemplateType.CAIRO_REFERENCE, 67 _TemplateType.IMG_REFERENCE) 68 69 70 _TestParams = Mapping[str, Any] 71 _MutableTestParams = MutableMapping[str, Any] 72 73 74 # All parameters that test definitions can specify to control test generation. 75 # Some have defaults values, used if the test definition doesn't specify them. 76 _TEST_DEFINITION_PARAMS = { 77 # Base parameters: 78 79 # Test name, which ultimately is used as filename. File variant dimension 80 # names (i.e. the 'file_variant_names' property below) are appended to this 81 # to produce unique filenames. 82 'name': None, 83 # The implementation of the test body. This should be JavaScript code, 84 # drawing on the canvas via the provided `canvas` and `ctx` objects. 85 # `canvas` will be an `HTMLCanvasElement` instance for 'HtmlCanvas' test 86 # type, else it will be an `OffscreenCanvas` instance. `ctx` will be a '2d' 87 # rendering context. 88 'code': None, 89 # Whether this test variant is enabled. This can be used to generate sparse 90 # variant grids, by making this parameter resolve to `True` only for certain 91 # combinations of variant dimensions, e.g.: 92 # - enabled: {{ variant_names[1] in ['foo', 'bar'] }}) 93 'enabled': 'true', 94 # Textual description for this test. This is used both as text in the 95 # generated HTML page, and as testharness.js test fixture description, for 96 # JavaScript tests (i.e. arguments to the `test()`, `async_test()` and 97 # `promise_test()` functions). 98 'desc': '', 99 # If specified, 'notes:' adds a `<p>` tag in the page showing this 100 # note. 101 'notes': '', 102 # Size of the canvas to use. 103 'size': (100, 50), 104 105 # Parameters controlling test runner execution: 106 107 # For reftests, specifies the tolerance in pixel difference allowed for the 108 # test to pass. By default, the test and reference must produce identical 109 # images. To allow a certain difference, specify as, for instance: 110 # fuzzy: maxDifference=0-2; totalPixels=0-1234 111 # In this example, up to 1234 pixels can differ by at most a difference of 112 # 2 on a any color channels. 113 'fuzzy': None, 114 # Used to control the test timeout, in case the test takes too long to 115 # complete. For instance: `timeout: long`. 116 'timeout': None, 117 118 # Parameters controlling what type of tests should be generated: 119 120 # List of the canvas types for which the test should be generated. Defaults 121 # to generating the test for all types. Options are: 122 # - 'HtmlCanvas': to test using an HTMLCanvasElement. 123 # - 'Offscreen': to test using an OffscreenCanvas on the main thread. 124 # - 'Worker': to test using an OffscreenCanvas in a worker. 125 'canvas_types': list(_CanvasType), 126 # Specifies the type of test fixture to generate. 127 # 128 # For testharness.js JavaScript tests, valid options are "sync", "async" or 129 # "promise", which produce standard `test()`, `async_test()` and 130 # `promise_test()` testharness fixtures respectively. For compatibility with 131 # old tests, leaving `test_type` unspecified produces a test using the 132 # legacy `_addTest` helper from canvas-tests.js. These are synchronous tests 133 # that can be made asynchronous by calling `deferTest()` and calling 134 # `t.done()` when the test completes. New tests should prefer specifying 135 # `test_type` explicitly. 136 # 137 # For reftests, generated tests are by default synchronous (if 'test_type' 138 # is not specified). Setting 'test_type' to "promise" makes it possible to 139 # use async/await syntax in the test body. In both cases, the test completes 140 # when the test body returns. Asynchronous APIs can be supported by wrapping 141 # them in promises awaiting on their completion. 142 'test_type': None, 143 # Causes the test generator to generate a reftest instead of a JavaScript 144 # test. Similarly to the `code:` parameter, 'reference:' should be set to 145 # JavaScript code drawing to a provided `canvas` object via a provided `ctx` 146 # 2D rendering context. The code generator will generate a reference HTML 147 # file that the test runner will use to validate the test result. Cannot be 148 # used in combination with 'html_reference:', 'cairo_reference:' or 149 # 'img_reference:'. 150 'reference': None, 151 # Similar to 'reference:', but the value is an HTML document instead of 152 # JavaScript drawing to a canvas. This is useful to use the DOM or an SVG 153 # drawing as a reference to compare the test result against. Cannot be used 154 # in combination with 'reference:', 'cairo_reference:' or 'img_reference:'. 155 'html_reference': None, 156 # Similar to 'reference:', but the value is Python code generating an image 157 # using the pycairo library. The Python code is provided with a `surface` 158 # and `cr` variable, instances of `cairo.ImageSurface` and `cairo.Context` 159 # respectively. Cannot be used in combination with 'reference:', 160 # 'html_reference:' or 'img_reference:'. 161 'cairo_reference': None, 162 # Similar to 'reference', but the value is the path to an image resource 163 # file to use as reference. When using 'cairo_reference:', the generated 164 # image path is assigned to 'img_reference:', for the template to use. A 165 # test could technically set 'img_reference:' directly, specifying a 166 # pre-generated image. This can be useful for plain and trivial images (e.g. 167 # '/images/green-100x50.png', but any non-trivial pre-generated image should 168 # be avoided because these can't easily be inspected and maintained if it 169 # needs to be revisited in the future. Cannot be used in combination with 170 # 'reference:', 'html_reference:' or 'cairo_reference:'. 171 'img_reference': None, 172 173 # Parameters adding HTML tags in the generated HTML files: 174 175 # Additional HTML attributes to pass to the '<canvas>' tag. e.g.: 176 # canvas: 'style="font-size: 144px"' 177 'canvas': None, 178 # If specified, the 'attribute' string is used as extra parameter to 179 # `canvas.getContext()`. For instance, using: 180 # attributes: '{alpha: False}' 181 # would create a context with: 182 # canvas.getContext('2d', {alpha: false}) 183 'attributes': None, 184 # List of image filenames to add `<img>` tag for. The same name is used as 185 # id, which can be used to get the img element from the test body. 186 'images': [], 187 # List of image filenames to add SVG `<image>` tag for. The same name is 188 # used as id, which can be used to get the image element from the test body. 189 'svgimages': [], 190 # List of custom fonts to load, by adding a `@font-face` CSS statement. 191 # Fonts a specified by their base filename, not their full path. For 192 # instance `fonts: ['CanvasTest']` would have the test load the font 193 # '/fonts/CanvasTest.ttf' 194 'fonts': [], 195 # By default, the fonts added to the CSS via 'fonts:' are used in the test 196 # HTML page in a hidden `<span>`, to make sure the fonts get loaded. The 197 # `<span>` tags can be omitted by setting `font_unused_in_dom: True`, 198 # allowing the test to validate what happens if the fonts aren't used in the 199 # page. 200 'font_unused_in_dom': False, 201 # Python code generating an expected image using the pycairo library. This 202 # expected image is included in the HTML test result page for 203 # HTMLCanvasElement JavaScript tests, only for informational purposes and 204 # for allowing manual visual verifications. It is NOT used by the test 205 # runner to automatically check for test success. To automate test 206 # validation, use a reftest instead, using the `reference`, 207 # `html_reference`, `cairo_reference` or `img_reference` config. 208 # 209 # The Python script must start with the (non-Pythonic) magic line: size x y 210 # Where x and y are the size of the image to generate. The remaining is 211 # standard Python code, where the variables `surface` and `cr` are 212 # respectively providing the `cairo.ImageSurface` and `cairo.Context` 213 # objects to use for drawing the image. 214 # 215 # 'expected' accepts two special values: 'green' and 'clear'. These 216 # respectively resolve to the images '/images/green-100x50.png' and 217 # '/images/clear-100x50.png'. The test definitions can alternatively pass an 218 # image filename explicitly by using `expected_img`. 219 'expected': None, 220 # When using the 'expected' option above, the name of the file that gets 221 # generated is stored in 'expected_img', for the template to use. Test 222 # definitions can alternatively specify a value for 'expected_img' directly, 223 # without using 'expected', by passing it a filename to an image resource, 224 # e.g. '/images/green-100x50.png'. 225 'expected_img': None, 226 227 # Test variants: 228 229 # List of dictionaries, defining the dimensions of a test variant grid. Each 230 # dictionary defines a variant dimension, with the dictionary keys 231 # corresponding to different variant names and the values corresponding to 232 # the parameters this variant should use. 233 # 234 # If only a single dictionary is provided, a different test will be 235 # generated for each entries of this dictionary. For instance, the following 236 # config will generate 2 tests: 237 # - name: 2d.example 238 # code: ctx.fillStyle = '{{ color }}'; 239 # variants: 240 # - red: {color: '#F00'} 241 # blue: {color: '#00F'} 242 # 243 # Will generate: 244 # 1) '2d.example.red', with the code: `ctx.fillStyle = '#F00'` 245 # 2) '2d.example.blue', with the code: `ctx.fillStyle = '#00F'` 246 # 247 # If more than one dictionaries are provided, each dictionary corresponds to 248 # a dimension in a multi-dimensional variant grid. For instance, the 249 # following config will generate 4 tests (using `variant_names[0]` to avoid 250 # duplicating the same string in the variant name and parameter): 251 # - name: 2d.grid 252 # code: ctx.{{ variant_names[0] }} = '{{ color }}'; 253 # variants: 254 # - fillStyle: 255 # shadowColor: 256 # - red: {color: '#F00'} 257 # blue: {color: '#00F'} 258 # 259 # Will generate: 260 # 1) '2d.grid.fillStyle.red', code: `ctx.fillStyle = '#F00';` 261 # 2) '2d.grid.fillStyle.blue', code: `ctx.fillStyle = '#00F';` 262 # 3) '2d.grid.shadowColor.red', code: `ctx.shadowColor = '#F00';` 263 # 4) '2d.grid.shadowColor.blue', code: `ctx.shadowColor = '#00F';` 264 # 265 # The parameters of a variant (e.g. the 'color' parameter in the example 266 # above) get merged over the base test parameter. For instance, a variant 267 # could have the property 'code:', which overrides the 'code:' property in 268 # the base test definition, if defined there: 269 # - name: 2d.parameter-override 270 # code: // Base code implementation. 271 # variants: 272 # - variant1: 273 # code: // Overrides base code implementation. 274 'variants': None, 275 # By default, each variant is generated to a different file. By using 276 # 'variants_layout:', variants can be generated as multiple tests in the 277 # same test file. If specified, 'variants_layout:' must be a list the same 278 # length as the 'variants:' list, that is, as long as there are variant 279 # dimensions. Each item in the `variants_layout` list indicate how that 280 # particular variant dimension should be expanded. Possible values are: 281 # 282 # - multi_files 283 # This the default behavior: the variants along this dimension get 284 # generated to different files. 285 # 286 # - single_file 287 # The variants in this dimension get rendered to the same file. 288 # 289 # If multiple dimensions are marked as 'single_file', these variants get 290 # laid-out in a grid whose width defaults to the number of variants in the 291 # first 'single_file' dimension (the grid width can be customized using 292 # 'grid_width:'). For instance: 293 # 294 # - name: grid-example 295 # variants: 296 # - A1: 297 # A2: 298 # - B1: 299 # B2: 300 # - C1: 301 # C2: 302 # - D1: 303 # D2: 304 # - E1: 305 # E2: 306 # variants_layout: 307 # - single_file 308 # - multi_files 309 # - single_file 310 # - multi_files 311 # - single_file 312 # 313 # Because this test has 2 'multi_files' dimensions with two variants each, 4 314 # files would be generated: 315 # - grid-example.B1.D1 316 # - grid-example.B1.D2 317 # - grid-example.B2.D1 318 # - grid-example.B2.D2 319 # 320 # Then, the 3 'single_file' dimensions would produce 2x2x2 = 8 tests in each 321 # of these files. For JavaScript tests, each of these tests would be 322 # generated in sequence, each with their own `test()`, `async_test()` or 323 # `promise_test()` fixture. Reftests on the other hand would produce a 2x2x2 324 # grid, as follows: 325 # A1.C1.E1 A2.C1.E1 326 # A1.C2.E1 A2.C2.E1 327 # A1.C1.E2 A2.C1.E2 328 # A1.C2.E2 A2.C2.E2 329 'variants_layout': None, 330 # The width of the grid generated by the 'single_file' variant_layout. If 331 # not specified, the size of the first 'single_file' variant dimension 332 # is used as grid width. 333 'grid_width': None, 334 # If `True`, the file variant dimension names (i.e. the `file_variant_names` 335 # property below) get appended to the test name. Setting this to `False` is 336 # useful if a custom name format is desired, for instance: 337 # name: my_test.{{ file_variant_name }}.tentative 338 'append_variants_to_name': True, 339 } 340 341 # Parameters automatically populated by the test generator. Test definitions 342 # cannot manually specify a value for these, but they can be used in parameter 343 # values using Jinja templating. 344 _GENERATED_PARAMS = { 345 # Set to either 'HtmlCanvas', 'Offscreen' or 'Worker' when rendering 346 # templates for the corresponding canvas type. Test definitions can use this 347 # parameter in Jinja `if` conditions to generate different code for 348 # different canvas types. 349 'canvas_type': None, 350 # List holding the file variant dimension names. These get appended to 351 # 'name' to form the test file name. 352 'file_variant_names': [], 353 # List of this variant grid dimension names. This uniquely identifies a 354 # single variant in a variant grid file. 355 'grid_variant_names': [], 356 # List of this variant dimension names, including both file and grid 357 # dimensions. 358 'variant_names': [], 359 # Same as `file_variant_names`, but concatenated into a single string. This 360 # is a useful to easily identify a variant file. 361 'file_variant_name': '', 362 # Same as `grid_variant_names`, but concatenated into a single string. This 363 # is a useful to easily identify a variant in a grid. 364 'grid_variant_name': '', 365 # Same as `variant_names`, but concatenated into a single string. This is a 366 # useful shorthand for tests having a single variant dimension. 367 'variant_name': '', 368 # For reftests, this is the reference file name that the test file links to. 369 'reference_file_link': None, 370 # Numerical ID uniquely identifying this variant in a variant grid. This can 371 # be used in `id` HTML attributes to allow each variant in a variant grid 372 # to have uniquely identifiable HTML tags. For instance, an `html_reference` 373 # with SVG code could give each variant a uniquely identifiable `<filter>` 374 # tags by doing: 375 # <filter id="my_filter{{ id }}"> 376 'id': 0, 377 # The file name of the test file being generated. 378 'file_name': None, 379 # Set to one of the enum values in `_TemplateType`, identifying the template 380 # being used to generate the test. 381 'template_type': None, 382 } 383 384 385 def _double_quote_escape(string: str) -> str: 386 return string.replace('\\', '\\\\').replace('"', '\\"') 387 388 389 def _escape_js(string: str) -> str: 390 string = _double_quote_escape(string) 391 # Kind of an ugly hack, for nicer failure-message output. 392 string = re.sub(r'\[(\w+)\]', r'[\\""+(\1)+"\\"]', string) 393 return string 394 395 396 def _expand_nonfinite(method: str, argstr: str, tail: str) -> str: 397 """ 398 >>> print _expand_nonfinite('f', '<0 a>, <0 b>', ';') 399 f(a, 0); 400 f(0, b); 401 f(a, b); 402 >>> print _expand_nonfinite('f', '<0 a>, <0 b c>, <0 d>', ';') 403 f(a, 0, 0); 404 f(0, b, 0); 405 f(0, c, 0); 406 f(0, 0, d); 407 f(a, b, 0); 408 f(a, b, d); 409 f(a, 0, d); 410 f(0, b, d); 411 """ 412 # argstr is "<valid-1 invalid1-1 invalid2-1 ...>, ..." (where usually 413 # 'invalid' is Infinity/-Infinity/NaN). 414 args = [] 415 for arg in argstr.split(', '): 416 match = re.match('<(.*)>', arg) 417 if match is None: 418 raise InvalidTestDefinitionError( 419 f'Expected arg to match format "<(.*)>", but was: {arg}') 420 a = match.group(1) 421 args.append(a.split(' ')) 422 calls = [] 423 # Start with the valid argument list. 424 call = [args[j][0] for j in range(len(args))] 425 # For each argument alone, try setting it to all its invalid values: 426 for i, arg in enumerate(args): 427 for a in arg[1:]: 428 c2 = call[:] 429 c2[i] = a 430 calls.append(c2) 431 # For all combinations of >= 2 arguments, try setting them to their 432 # first invalid values. (Don't do all invalid values, because the 433 # number of combinations explodes.) 434 def f(c: List[str], start: int, depth: int) -> None: 435 for i in range(start, len(args)): 436 if len(args[i]) > 1: 437 a = args[i][1] 438 c2 = c[:] 439 c2[i] = a 440 if depth > 0: 441 calls.append(c2) 442 f(c2, i + 1, depth + 1) 443 444 f(call, 0, 0) 445 446 str_calls = (', '.join(c) for c in calls) 447 return '\n'.join(f'{method}({params}){tail}' for params in str_calls) 448 449 450 def _get_test_sub_dir(name: str, name_to_sub_dir: Mapping[str, str]) -> str: 451 for prefix in sorted(name_to_sub_dir.keys(), key=len, reverse=True): 452 if name.startswith(prefix): 453 return name_to_sub_dir[prefix] 454 raise InvalidTestDefinitionError( 455 f'Test "{name}" has no defined target directory mapping') 456 457 458 def _remove_extra_newlines(text: str) -> str: 459 """Remove newlines if a backslash is found at end of line.""" 460 # Lines ending with '\' gets their newline character removed. 461 text = re.sub(r'\\\n', '', text, flags=re.MULTILINE | re.DOTALL) 462 463 # Lines ending with '\-' gets their newline and any leading white spaces on 464 # the following line removed. 465 text = re.sub(r'\\-\n\s*', '', text, flags=re.MULTILINE | re.DOTALL) 466 return text 467 468 469 def _expand_test_code(code: str) -> str: 470 code = _remove_extra_newlines(code) 471 472 code = re.sub(r' @moz-todo', '', code) 473 474 code = re.sub(r'@moz-UniversalBrowserRead;', '', code) 475 476 code = re.sub(r'@nonfinite ([^(]+)\(([^)]+)\)(.*)', lambda m: 477 _expand_nonfinite(m.group(1), m.group(2), m.group(3)), 478 code) # Must come before '@assert throws'. 479 480 code = re.sub(r'@assert pixel (\d+,\d+) == (\d+,\d+,\d+,\d+);', 481 r'_assertPixel(canvas, \1, \2);', code) 482 483 code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+);', 484 r'_assertPixelApprox(canvas, \1, \2, 2);', code) 485 486 code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+) \+/- (\d+);', 487 r'_assertPixelApprox(canvas, \1, \2, \3);', code) 488 489 code = re.sub(r'@assert throws (\S+_ERR) (.*?);$', 490 r'assert_throws_dom("\1", function() { \2; });', code, 491 flags=re.MULTILINE | re.DOTALL) 492 493 code = re.sub(r'@assert throws (\S+Error) (.*?);$', 494 r'assert_throws_js(\1, function() { \2; });', code, 495 flags=re.MULTILINE | re.DOTALL) 496 497 code = re.sub( 498 r'@assert (.*) === (.*);', lambda m: 499 (f'_assertSame({m.group(1)}, {m.group(2)}, ' 500 f'"{_escape_js(m.group(1))}", "{_escape_js(m.group(2))}");'), code) 501 502 code = re.sub( 503 r'@assert (.*) !== (.*);', lambda m: 504 (f'_assertDifferent({m.group(1)}, {m.group(2)}, ' 505 f'"{_escape_js(m.group(1))}", "{_escape_js(m.group(2))}");'), code) 506 507 code = re.sub( 508 r'@assert (.*) =~ (.*);', 509 lambda m: f'assert_regexp_match({m.group(1)}, {m.group(2)});', code) 510 511 code = re.sub( 512 r'@assert (.*);', 513 lambda m: f'_assert({m.group(1)}, "{_escape_js(m.group(1))}");', code) 514 515 assert '@' not in code 516 517 return code 518 519 520 class MutableDictLoader(jinja2.BaseLoader): 521 """Loads Jinja templates from a `dict` that can be updated. 522 523 This is essentially a version of `jinja2.DictLoader` whose content can be 524 changed. `jinja2.DictLoader` accepts a `dict` at construction time and that 525 `dict` cannot be changed. The templates served by `MutableDictLoader` on the 526 other hand can be updated by calling `set_templates(new_templates)`. This is 527 needed because we reuse the environment to render different tests and 528 variants, each of which will have different templates. 529 """ 530 531 def __init__(self) -> None: 532 self._templates = dict() # type: Mapping[str, Any] 533 534 def set_templates(self, new_templates: Mapping[str, Any]) -> None: 535 """Changes the dict from which templates are loaded.""" 536 self._templates = new_templates 537 538 def get_source( 539 self, environment: jinja2.Environment, template: str 540 ) -> Tuple[str, str, Callable[[], bool]]: 541 """Loads a template from the current template dict.""" 542 del environment # Unused. 543 source = self._templates.get(template) 544 if source is None: 545 raise jinja2.TemplateNotFound(template) 546 if not isinstance(source, str): 547 raise InvalidTestDefinitionError( 548 f'Param "{template}" must be an str to be usable as Jinja ' 549 'template.') 550 return source, template, lambda: source == self._templates.get(template) 551 552 553 class TemplateLoaderActivator: 554 """Helper class used to set a given params dict in a MutableDictLoader. 555 556 Jinja requires custom loaders to be registered in the environment and thus, 557 we can't dynamically change them. We would need this to allow different test 558 variants to have different templates. Using a `TemplateLoaderActivator`, 559 the code can "activate" the templates for a given variant before rendering 560 strings for that variant. For instance: 561 562 loader = MutableDictLoader() 563 jinja_env = jinja2.Environment(loader=[loader]) 564 565 templates1 = {'macros': '{% macro foo() %}foo{% endmacro %}'} 566 activator1 = TemplateLoaderActivator(loader, templates1) 567 568 templates2 = {'macros': '{% macro foo() %}bar{% endmacro %}'} 569 activator2 = TemplateLoaderActivator(loader, templates2) 570 571 main_template = ''' 572 {% import 'macros' as t %} 573 {{ t.foo() }} 574 ''' 575 576 # Render `main_template`, loading 'macros' from `templates1. 577 activator1.activate() 578 jinja_env.from_string(main_template).render(params1)) 579 580 # Render `main_template`, loading 'macros' from `templates2. 581 activator2.activate() 582 jinja_env.from_string(main_template).render(params2)) 583 584 """ 585 586 def __init__(self, loader: MutableDictLoader, params: _TestParams) -> None: 587 self._loader = loader 588 self._params = params 589 590 def activate(self): 591 self._loader.set_templates(self._params) 592 593 594 class _LazyRenderedStr(collections.UserString): 595 """A custom str type that renders it's content with Jinja when accessed. 596 597 This is an str-like type, storing a Jinja template, but returning the 598 rendered version of that template when the string is accessed. The rendered 599 result is cached and returned on subsequent accesses. 600 601 This allows template parameters to be themselves templates. Template 602 parameters can then refer to each other and they'll be rendered in the right 603 order, in reverse order of access. 604 605 For instance: 606 607 params = {} 608 make_lazy = lambda value: _LazyRenderedStr( 609 jinja_env, loader_activator, params, value) 610 611 params.update({ 612 'expected_value': make_lazy('rgba({{ color | join(", ") }})'), 613 'color': [0, 255, 0, make_lazy('{{ alpha }}')], 614 'alpha': 0.5, 615 }) 616 617 main_template = 'assert value == "{{ expected_value }}"' 618 result = jinja_env.from_string(main_template).render(params) 619 620 In this example, upon rendering `main_template`, Jinja will first read 621 `expected_value`, which reads `color`, which reads `alpha`. These will be 622 rendered in reverse order, with `color` resolving to `[0, 255, 0, '0.5']`, 623 `expected_value` resolving to 'rgba(0, 255, 0, 0.5)' and the final render 624 resolving to: 'assert value == "rgba(0, 255, 0, 0.5)"' 625 """ 626 627 def __init__(self, jinja_env: jinja2.Environment, 628 loader_activator: TemplateLoaderActivator, 629 params: _TestParams, value: str): 630 # Don't call `super().__init__`, because we want to override `self.data` 631 # to be a property instead of a member variable. 632 # pylint: disable=super-init-not-called 633 self._jinja_env = jinja_env 634 self._loader_activator = loader_activator 635 self._params = params 636 self._value = value 637 self._rendered = None 638 639 @property 640 def data(self): 641 """Property returning the content of the `UserString`. 642 643 This `_LazyRenderedStr` will be rendered on the first access. The 644 rendered result is cached and returned directly on subsequent 645 accesses.""" 646 if self._rendered is None: 647 self._loader_activator.activate() 648 self._rendered = ( 649 self._jinja_env.from_string(self._value).render(self._params)) 650 return self._rendered 651 652 @property 653 def __class__(self): 654 """Makes `UserString` return any newly created strings as `str` objects. 655 656 `UserString` functions returning a new string (e.g. `strip()`, 657 `lower()`, etc.) normally return a string of the same type as the input 658 `UserString`. It does do by using `__class__` to know the actual user 659 string type. In our case, the result of these operations will always 660 return a plain `str`, since any templating will have been rendered when 661 reading the input string via `self.data`.""" 662 return str 663 664 665 def _make_lazy_rendered(jinja_env: jinja2.Environment, 666 loader_activator: TemplateLoaderActivator, 667 params: _TestParams, 668 value: Any) -> Any: 669 """Recursively converts `value` to a _LazyRenderedStr. 670 671 If `value` is a data structure, this function recurses into that structure 672 and converts leaf objects. Any `str` found containing Jinja tags are 673 converted to _LazyRenderedStr. 674 """ 675 if isinstance(value, str) and ('{{' in value or '{%' in value): 676 return _LazyRenderedStr(jinja_env, loader_activator, params, value) 677 if isinstance(value, list): 678 return [_make_lazy_rendered(jinja_env, loader_activator, params, v) 679 for v in value] 680 if isinstance(value, tuple): 681 return tuple(_make_lazy_rendered(jinja_env, loader_activator, params, v) 682 for v in value) 683 if isinstance(value, dict): 684 return {k: _make_lazy_rendered(jinja_env, loader_activator, params, v) 685 for k, v in value.items()} 686 return value 687 688 689 def _ensure_rendered(value: Any) -> Any: 690 """Recursively makes sure that all _LazyRenderedStr in `value` are rendered. 691 692 If `value` is a data structure, this function recurses into that structure 693 and renders any _LazyRenderedStr found.""" 694 if isinstance(value, _LazyRenderedStr): 695 return str(value) 696 if isinstance(value, list): 697 return [_ensure_rendered(v) for v in value] 698 if isinstance(value, tuple): 699 return tuple(_ensure_rendered(v) for v in value) 700 if isinstance(value, dict): 701 return {k: _ensure_rendered(v) for k, v in value.items()} 702 return value 703 704 705 @dataclasses.dataclass 706 class _OutputPaths: 707 element: pathlib.Path 708 offscreen: pathlib.Path 709 710 def sub_path(self, sub_dir: str): 711 """Create a new _OutputPaths that is a subpath of this _OutputPath.""" 712 return _OutputPaths( 713 element=self.element / _ensure_rendered(sub_dir), 714 offscreen=self.offscreen / _ensure_rendered(sub_dir)) 715 716 def path_for_canvas_type(self, canvas_type: _CanvasType) -> pathlib.Path: 717 return (self.element if canvas_type == _CanvasType.HTML_CANVAS 718 else self.offscreen) 719 720 def mkdir(self) -> None: 721 """Creates element and offscreen directories, if they don't exist.""" 722 self.element.mkdir(parents=True, exist_ok=True) 723 self.offscreen.mkdir(parents=True, exist_ok=True) 724 725 726 def _check_reserved_params(test: _TestParams): 727 for param in _GENERATED_PARAMS: 728 if test.get(param) is not None: 729 raise InvalidTestDefinitionError( 730 f'Parameter "{param}:" is reserved and cannot be manually ' 731 'specified in test definitions.') 732 733 734 def _validate_test(test: _TestParams): 735 for param in ['name', 'code']: 736 if test.get(param) is None: 737 raise InvalidTestDefinitionError( 738 f'Test parameter "{param}" must be specified.') 739 740 if test.get('expected', '') == 'green' and re.search( 741 r'@assert pixel .* 0,0,0,0;', test['code']): 742 print(f'Probable incorrect pixel test in {test["name"]}') 743 744 if 'size' in test and (not isinstance(test['size'], tuple) 745 or len(test['size']) != 2): 746 raise InvalidTestDefinitionError( 747 f'Invalid canvas size "{test["size"]}" in test {test["name"]}. ' 748 'Expected an array with two numbers.') 749 750 if test['template_type'] == _TemplateType.TESTHARNESS: 751 valid_test_types = {'sync', 'async', 'promise'} 752 else: 753 valid_test_types = {'promise'} 754 755 test_type = test.get('test_type') 756 if test_type is not None and test_type not in valid_test_types: 757 raise InvalidTestDefinitionError( 758 f'Invalid test_type: {test_type}. ' 759 f'Valid values are: {valid_test_types}.') 760 761 762 def _render(jinja_env: jinja2.Environment, 763 template_name: str, 764 params: _TestParams, output_file_name: str): 765 template = jinja_env.get_template(template_name) 766 file_content = template.render(params) 767 pathlib.Path(output_file_name).write_text(file_content, 'utf-8') 768 769 770 def _write_cairo_images(pycairo_code: str, output_file: pathlib.Path) -> None: 771 """Creates a png from pycairo code and write it to `output_file`.""" 772 full_code = (f'{pycairo_code}\n' 773 f'surface.write_to_png("{output_file}")\n') 774 eval(compile(full_code, '<string>', 'exec'), { 775 'cairo': cairo, 776 'math': math, 777 }) 778 779 780 class _Variant(): 781 782 def __init__(self, params: _MutableTestParams) -> None: 783 # Raw parameters, as specified in YAML, defining this test variant. 784 self._params = params # type: _MutableTestParams 785 # Parameters rendered for each enabled canvas types. 786 self._canvas_type_params = { 787 } # type: MutableMapping[_CanvasType, _MutableTestParams] 788 789 @property 790 def params(self) -> _MutableTestParams: 791 """Returns this variant's raw param dict, as it's defined in YAML.""" 792 return self._params 793 794 @property 795 def canvas_type_params(self) -> MutableMapping[_CanvasType, 796 _MutableTestParams]: 797 """Returns this variant's param dict for different canvas types.""" 798 return self._canvas_type_params 799 800 @staticmethod 801 def create_with_defaults(test: _TestParams) -> '_Variant': 802 """Create a _Variant from the specified params. 803 804 Default values are added for certain parameters, if missing.""" 805 # Pick up all default values from the parameter definition constants, 806 # but drop all `None` values as they are only there as placeholders, to 807 # allow all parameters to be listed for documentation purposes. 808 params = {k: v 809 for defaults in (_TEST_DEFINITION_PARAMS, _GENERATED_PARAMS) 810 for k, v in defaults.items() 811 if v is not None} 812 params.update(test) 813 814 if 'variants' in params: 815 del params['variants'] 816 return _Variant(params) 817 818 def merge_params(self, params: _TestParams) -> '_Variant': 819 """Returns a new `_Variant` that merges `self.params` and `params`.""" 820 new_params = copy.deepcopy(self._params) 821 new_params.update(params) 822 return _Variant(new_params) 823 824 def _add_variant_name(self, name: str) -> None: 825 self._params['variant_name'] += ( 826 ('.' if self.params['variant_name'] else '') + name) 827 self._params['variant_names'] += [name] 828 829 def with_grid_variant_name(self, name: str) -> '_Variant': 830 """Addend a variant name to include in the grid element label.""" 831 self._add_variant_name(name) 832 self._params['grid_variant_name'] += ( 833 ('.' if self.params['grid_variant_name'] else '') + name) 834 self._params['grid_variant_names'] += [name] 835 return self 836 837 def with_file_variant_name(self, name: str) -> '_Variant': 838 """Addend a variant name to include in the generated file name.""" 839 self._add_variant_name(name) 840 self._params['file_variant_name'] += ( 841 ('.' if self.params['file_variant_name'] else '') + name) 842 self._params['file_variant_names'] += [name] 843 if self.params.get('append_variants_to_name', True): 844 self._params['name'] += '.' + name 845 return self 846 847 def _get_file_name(self) -> str: 848 file_name = self.params['name'] 849 850 if 'manual' in self.params: 851 file_name += '-manual' 852 853 return file_name 854 855 def _get_canvas_types(self) -> FrozenSet[_CanvasType]: 856 canvas_types = self.params.get('canvas_types', _CanvasType) 857 invalid_types = { 858 type 859 for type in canvas_types if type not in list(_CanvasType) 860 } 861 if invalid_types: 862 raise InvalidTestDefinitionError( 863 f'Invalid canvas_types: {list(invalid_types)}. ' 864 f'Accepted values are: {[t.value for t in _CanvasType]}') 865 return frozenset(_CanvasType(t) for t in canvas_types) 866 867 def _get_template_type(self) -> _TemplateType: 868 reference_types = sum(t in self.params for t in _REFERENCE_TEMPLATES) 869 if reference_types > 1: 870 raise InvalidTestDefinitionError( 871 f'Test {self.params["name"]} is invalid, only one of ' 872 f'{[t.value for t in _REFERENCE_TEMPLATES]} can be specified ' 873 'at the same time.') 874 875 for template_type in _REFERENCE_TEMPLATES: 876 if template_type.value in self.params: 877 return template_type 878 return _TemplateType.TESTHARNESS 879 880 def finalize_params(self, jinja_env: jinja2.Environment, 881 variant_id: int, 882 params_template_loader: MutableDictLoader) -> None: 883 """Finalize this variant by adding computed param fields.""" 884 self._params['id'] = variant_id 885 self._params['file_name'] = self._get_file_name() 886 self._params['canvas_types'] = self._get_canvas_types() 887 self._params['template_type'] = self._get_template_type() 888 889 if isinstance(self._params['size'], list): 890 self._params['size'] = tuple(self._params['size']) 891 892 loader_activator = TemplateLoaderActivator(params_template_loader, 893 self._params) 894 for canvas_type in self.params['canvas_types']: 895 params = {'canvas_type': canvas_type} 896 params.update( 897 {k: _make_lazy_rendered(jinja_env, loader_activator, params, v) 898 for k, v in self._params.items()}) 899 self._canvas_type_params[canvas_type] = params 900 901 for name in ('code', 'reference', 'html_reference', 902 'cairo_reference'): 903 param = params.get(name) 904 if param is not None: 905 params[name] = _expand_test_code(_ensure_rendered(param)) 906 907 _validate_test(self._params) 908 909 def generate_expected_image(self, output_dirs: _OutputPaths) -> None: 910 """Creates an expected image using Cairo and save filename in params.""" 911 # Expected images are only needed for HTML canvas tests. 912 params = self._canvas_type_params.get(_CanvasType.HTML_CANVAS) 913 if not params: 914 return 915 916 expected = _ensure_rendered(params['expected']) 917 918 if expected == 'green': 919 params['expected_img'] = '/images/green-100x50.png' 920 return 921 if expected == 'clear': 922 params['expected_img'] = '/images/clear-100x50.png' 923 return 924 expected = re.sub( 925 r'^size (\d+) (\d+)', 926 r'surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, \1, \2)' 927 r'\ncr = cairo.Context(surface)', expected) 928 929 img_filename = f'{params["name"]}.png' 930 _write_cairo_images(expected, output_dirs.element / img_filename) 931 params['expected_img'] = img_filename 932 933 934 class _VariantGrid: 935 936 def __init__(self, variants: List[_Variant], grid_width: int) -> None: 937 self._variants = variants 938 self._grid_width = grid_width 939 940 # Parameters rendered for each enabled canvas types. 941 self._canvas_type_params = { 942 } # type: Mapping[_CanvasType, _MutableTestParams] 943 self._enabled = None 944 self._file_name = None 945 self._canvas_types = None 946 self._template_type = None 947 948 @property 949 def variants(self) -> List[_Variant]: 950 """Read only getter for the list of variant in this grid.""" 951 return self._variants 952 953 @property 954 def enabled(self) -> bool: 955 """File name to which this grid will be written.""" 956 if self._enabled is None: 957 enabled_str = self._unique_param(_CanvasType, 'enabled') 958 self._enabled = (enabled_str.strip().lower() == 'true') 959 return self._enabled 960 961 @property 962 def file_name(self) -> str: 963 """File name to which this grid will be written.""" 964 if self._file_name is None: 965 self._file_name = self._unique_param(_CanvasType, 'file_name') 966 return self._file_name 967 968 @property 969 def canvas_types(self) -> FrozenSet[_CanvasType]: 970 """Returns the set of all _CanvasType used by this grid's variants.""" 971 if self._canvas_types is None: 972 self._canvas_types = self._param_set(_CanvasType, 'canvas_types') 973 return self._canvas_types 974 975 @property 976 def template_type(self) -> _TemplateType: 977 """Returns the type of Jinja template needed to render this grid.""" 978 if self._template_type is None: 979 self._template_type = self._unique_param(_CanvasType, 980 'template_type') 981 return self._template_type 982 983 def finalize(self, jinja_env: jinja2.Environment, 984 params_template_loader: MutableDictLoader): 985 """Finalize this grid's variants, adding computed params fields.""" 986 for variant_id, variant in enumerate(self.variants): 987 variant.finalize_params(jinja_env, variant_id, 988 params_template_loader) 989 990 if len(self.variants) == 1: 991 self._canvas_type_params = self.variants[0].canvas_type_params 992 else: 993 self._canvas_type_params = self._get_grid_params() 994 995 def add_dimension(self, variants: Mapping[str, 996 _TestParams]) -> '_VariantGrid': 997 """Adds a variant dimension to this variant grid. 998 999 If the grid currently has N variants, adding a dimension with M variants 1000 results in a grid containing N*M variants. Of course, we can't display 1001 more than 2 dimensions on a 2D screen, so adding dimensions beyond 2 1002 repeats all previous dimensions down vertically, with the grid width 1003 set to the number of variants of the first dimension (unless overridden 1004 by setting `grid_width`). For instance, a 3D variant space with 1005 dimensions 3 x 2 x 2 will result in this layout: 1006 000 100 200 1007 010 110 210 1008 1009 001 101 201 1010 011 111 211 1011 """ 1012 new_variants = [ 1013 old_variant.merge_params(params or {}).with_grid_variant_name(name) 1014 for name, params in variants.items() 1015 for old_variant in self.variants 1016 ] 1017 # The first dimension dictates the grid-width, unless it was specified 1018 # beforehand via the test params. 1019 new_grid_width = (self._grid_width 1020 if self._grid_width > 1 else len(variants)) 1021 return _VariantGrid(variants=new_variants, grid_width=new_grid_width) 1022 1023 def merge_params(self, name: str, params: _TestParams) -> '_VariantGrid': 1024 """Merges the specified `params` into every variant of this grid.""" 1025 return _VariantGrid(variants=[ 1026 variant.merge_params(params).with_file_variant_name(name) 1027 for variant in self.variants 1028 ], 1029 grid_width=self._grid_width) 1030 1031 def _variants_for_canvas_type( 1032 self, canvas_type: _CanvasType) -> List[_TestParams]: 1033 """Returns the variants of this grid enabled for `canvas_type`.""" 1034 return [ 1035 v.canvas_type_params[canvas_type] 1036 for v in self.variants 1037 if canvas_type in v.canvas_type_params 1038 ] 1039 1040 def _unique_param( 1041 self, canvas_types: Container[_CanvasType], name: str) -> Any: 1042 """Returns the value of the `name` param for this grid. 1043 1044 All the variants for all canvas types in `canvas_types` of this grid 1045 must agree on the same value for this parameter, or else an exception is 1046 thrown.""" 1047 values = {_ensure_rendered(params.get(name)) 1048 for variant in self.variants 1049 for type, params in variant.canvas_type_params.items() 1050 if type in canvas_types} 1051 if len(values) != 1: 1052 raise InvalidTestDefinitionError( 1053 'All variants in a variant grid must use the same value ' 1054 f'for property "{name}". Got these values: {values}. ' 1055 'Consider specifying the property outside of grid ' 1056 'variants dimensions (in the base test definition or in a ' 1057 'file variant dimension)') 1058 return values.pop() 1059 1060 def _param_set(self, canvas_types: Container[_CanvasType], name: str): 1061 """Returns the set of all values this grid has for the `name` param. 1062 1063 The `name` parameter of each variant is expected to be a sequence. These 1064 are all accumulated in a set and returned. The values are accumulated 1065 across all canvas types in `canvas_types`.""" 1066 return frozenset(sum([list(_ensure_rendered(params.get(name, []))) 1067 for v in self.variants 1068 for type, params in v.canvas_type_params.items() 1069 if type in canvas_types], 1070 [])) 1071 1072 def _get_grid_params(self) -> Mapping[_CanvasType, _MutableTestParams]: 1073 """Returns the params dict needed to render this grid with Jinja.""" 1074 grid_params = {} 1075 for canvas_type in self.canvas_types: 1076 params = grid_params[canvas_type] = {} 1077 params.update({ 1078 'variants': self._variants_for_canvas_type(canvas_type), 1079 'grid_width': self._grid_width, 1080 'name': self._unique_param([canvas_type], 'name'), 1081 'test_type': self._unique_param([canvas_type], 'test_type'), 1082 'fuzzy': self._unique_param([canvas_type], 'fuzzy'), 1083 'timeout': self._unique_param([canvas_type], 'timeout'), 1084 'notes': self._unique_param([canvas_type], 'notes'), 1085 'images': self._param_set([canvas_type], 'images'), 1086 'svgimages': self._param_set([canvas_type], 'svgimages'), 1087 'fonts': self._param_set([canvas_type], 'fonts'), 1088 }) 1089 if self.template_type in _REFERENCE_TEMPLATES: 1090 params['desc'] = self._unique_param([canvas_type], 'desc') 1091 return grid_params 1092 1093 def _write_reference_test(self, jinja_env: jinja2.Environment, 1094 output_files: _OutputPaths): 1095 grid = '_grid' if len(self.variants) > 1 else '' 1096 1097 # If variants don't all use the same offscreen and worker canvas types, 1098 # the offscreen and worker grids won't be identical. The worker test 1099 # therefore can't reuse the offscreen reference file. 1100 offscreen_types = {_CanvasType.OFFSCREEN_CANVAS, _CanvasType.WORKER} 1101 needs_worker_reference = len({ 1102 variant.params['canvas_types'] & offscreen_types 1103 for variant in self.variants 1104 }) != 1 1105 1106 test_templates = { 1107 _CanvasType.HTML_CANVAS: f'reftest_element{grid}.html', 1108 _CanvasType.OFFSCREEN_CANVAS: f'reftest_offscreen{grid}.html', 1109 _CanvasType.WORKER: f'reftest_worker{grid}.html', 1110 } 1111 ref_templates = { 1112 _TemplateType.REFERENCE: f'reftest_element{grid}.html', 1113 _TemplateType.HTML_REFERENCE: f'reftest{grid}.html', 1114 _TemplateType.CAIRO_REFERENCE: f'reftest_img{grid}.html', 1115 _TemplateType.IMG_REFERENCE: f'reftest_img{grid}.html', 1116 } 1117 test_output_paths = { 1118 _CanvasType.HTML_CANVAS: f'{output_files.element}.html', 1119 _CanvasType.OFFSCREEN_CANVAS: f'{output_files.offscreen}.html', 1120 _CanvasType.WORKER: f'{output_files.offscreen}.w.html', 1121 } 1122 ref_output_paths = { 1123 _CanvasType.HTML_CANVAS: f'{output_files.element}-expected.html', 1124 _CanvasType.OFFSCREEN_CANVAS: 1125 f'{output_files.offscreen}-expected.html', 1126 _CanvasType.WORKER: ( 1127 f'{output_files.offscreen}.w-expected.html' 1128 if needs_worker_reference 1129 else f'{output_files.offscreen}-expected.html'), 1130 } 1131 for canvas_type, params in self._canvas_type_params.items(): 1132 # Generate reference file. 1133 if canvas_type != _CanvasType.WORKER or needs_worker_reference: 1134 _render(jinja_env, ref_templates[self.template_type], params, 1135 ref_output_paths[canvas_type]) 1136 1137 # Generate test file, with a link to the reference file. 1138 params['reference_file_link'] = pathlib.Path( 1139 ref_output_paths[canvas_type]).name 1140 _render(jinja_env, test_templates[canvas_type], params, 1141 test_output_paths[canvas_type]) 1142 1143 def _write_testharness_test(self, jinja_env: jinja2.Environment, 1144 output_files: _OutputPaths): 1145 grid = '_grid' if len(self.variants) > 1 else '' 1146 1147 templates = { 1148 _CanvasType.HTML_CANVAS: f'testharness_element{grid}.html', 1149 _CanvasType.OFFSCREEN_CANVAS: f'testharness_offscreen{grid}.html', 1150 _CanvasType.WORKER: f'testharness_worker{grid}.js', 1151 } 1152 test_output_files = { 1153 _CanvasType.HTML_CANVAS: f'{output_files.element}.html', 1154 _CanvasType.OFFSCREEN_CANVAS: f'{output_files.offscreen}.html', 1155 _CanvasType.WORKER: f'{output_files.offscreen}.worker.js', 1156 } 1157 1158 # Create test cases for canvas, offscreencanvas and worker. 1159 for canvas_type, params in self._canvas_type_params.items(): 1160 _render(jinja_env, templates[canvas_type], params, 1161 test_output_files[canvas_type]) 1162 1163 def _generate_cairo_reference_grid(self, 1164 canvas_type: _CanvasType, 1165 output_dirs: _OutputPaths) -> None: 1166 """Generate this grid's expected image from Cairo code, if needed. 1167 1168 In order to cut on the number of files generated, the expected image 1169 of all the variants in this grid are packed into a single PNG. The 1170 expected HTML then contains a grid of <img> tags, each showing a portion 1171 of the PNG file.""" 1172 if not any(v.canvas_type_params[canvas_type].get('cairo_reference') 1173 for v in self.variants): 1174 return 1175 1176 width, height = self._unique_param([canvas_type], 'size') 1177 cairo_code = '' 1178 1179 # First generate a function producing a Cairo surface with the expected 1180 # image for each variant in the grid. The function is needed to provide 1181 # a scope isolating the variant code from each other. 1182 for idx, variant in enumerate(self._variants): 1183 cairo_ref = variant.canvas_type_params[canvas_type].get( 1184 'cairo_reference') 1185 if not cairo_ref: 1186 raise InvalidTestDefinitionError( 1187 'When used, "cairo_reference" must be specified for all ' 1188 'test variants.') 1189 cairo_code += textwrap.dedent(f'''\ 1190 def draw_ref{idx}(): 1191 surface = cairo.ImageSurface( 1192 cairo.FORMAT_ARGB32, {width}, {height}) 1193 cr = cairo.Context(surface) 1194 {{}} 1195 return surface 1196 ''').format(textwrap.indent(cairo_ref, ' ')) 1197 1198 # Write all variant images into the final surface. 1199 surface_width = width * self._grid_width 1200 surface_height = (height * 1201 math.ceil(len(self._variants) / self._grid_width)) 1202 cairo_code += textwrap.dedent(f'''\ 1203 surface = cairo.ImageSurface( 1204 cairo.FORMAT_ARGB32, {surface_width}, {surface_height}) 1205 cr = cairo.Context(surface) 1206 ''') 1207 for idx, variant in enumerate(self._variants): 1208 x_pos = int(idx % self._grid_width) * width 1209 y_pos = int(idx / self._grid_width) * height 1210 cairo_code += textwrap.dedent(f'''\ 1211 cr.set_source_surface(draw_ref{idx}(), {x_pos}, {y_pos}) 1212 cr.paint() 1213 ''') 1214 1215 img_filename = f'{self.file_name}.png' 1216 output_dir = output_dirs.path_for_canvas_type(canvas_type) 1217 _write_cairo_images(cairo_code, output_dir / img_filename) 1218 for v in self._variants: 1219 v.canvas_type_params[canvas_type]['img_reference'] = img_filename 1220 1221 def _generate_cairo_images(self, output_dirs: _OutputPaths) -> None: 1222 """Generates the pycairo images found in the YAML test definition.""" 1223 # 'expected:' is only used for HTML_CANVAS tests. 1224 has_expected = any(v.canvas_type_params 1225 .get(_CanvasType.HTML_CANVAS, {}) 1226 .get('expected') for v in self._variants) 1227 has_cairo_reference = any( 1228 params.get('cairo_reference') 1229 for v in self._variants 1230 for params in v.canvas_type_params.values()) 1231 1232 if has_expected and has_cairo_reference: 1233 raise InvalidTestDefinitionError( 1234 'Parameters "expected" and "cairo_reference" can\'t be both ' 1235 'used at the same time.') 1236 1237 if has_expected: 1238 if len(self.variants) != 1: 1239 raise InvalidTestDefinitionError( 1240 'Parameter "expected" is not supported for variant grids.') 1241 if self.template_type != _TemplateType.TESTHARNESS: 1242 raise InvalidTestDefinitionError( 1243 'Parameter "expected" is not supported in reference ' 1244 'tests.') 1245 self.variants[0].generate_expected_image(output_dirs) 1246 elif has_cairo_reference: 1247 for canvas_type in _CanvasType: 1248 self._generate_cairo_reference_grid(canvas_type, output_dirs) 1249 1250 def generate_test(self, jinja_env: jinja2.Environment, 1251 output_dirs: _OutputPaths) -> None: 1252 """Generate the test files to the specified output dirs.""" 1253 self._generate_cairo_images(output_dirs) 1254 1255 output_files = output_dirs.sub_path(self.file_name) 1256 1257 if self.template_type in _REFERENCE_TEMPLATES: 1258 self._write_reference_test(jinja_env, output_files) 1259 else: 1260 self._write_testharness_test(jinja_env, output_files) 1261 1262 1263 class _VariantLayout(str, enum.Enum): 1264 SINGLE_FILE = 'single_file' 1265 MULTI_FILES = 'multi_files' 1266 1267 1268 @dataclasses.dataclass 1269 class _VariantDimension: 1270 variants: Mapping[str, _TestParams] 1271 layout: _VariantLayout 1272 1273 1274 def _get_variant_dimensions(params: _TestParams) -> List[_VariantDimension]: 1275 variants = params.get('variants', []) 1276 if not isinstance(variants, list): 1277 raise InvalidTestDefinitionError( 1278 textwrap.dedent(""" 1279 Variants must be specified as a list of variant dimensions, e.g.: 1280 variants: 1281 - dimension1-variant1: 1282 param: ... 1283 dimension1-variant2: 1284 param: ... 1285 - dimension2-variant1: 1286 param: ... 1287 dimension2-variant2: 1288 param: ...""")) 1289 1290 variants_layout = params.get('variants_layout', 1291 [_VariantLayout.MULTI_FILES] * len(variants)) 1292 if len(variants) != len(variants_layout): 1293 raise InvalidTestDefinitionError( 1294 'variants and variants_layout must be lists of the same size') 1295 invalid_layouts = [ 1296 l for l in variants_layout if l not in list(_VariantLayout) 1297 ] 1298 if invalid_layouts: 1299 raise InvalidTestDefinitionError('Invalid variants_layout: ' + 1300 ', '.join(invalid_layouts) + 1301 '. Valid layouts are: ' + 1302 ', '.join(_VariantLayout)) 1303 1304 return [ 1305 _VariantDimension(z[0], z[1]) for z in zip(variants, variants_layout) 1306 ] 1307 1308 1309 def _get_variant_grids( 1310 test: _TestParams, 1311 jinja_env: jinja2.Environment, 1312 params_template_loader: MutableDictLoader 1313 ) -> List[_VariantGrid]: 1314 base_variant = _Variant.create_with_defaults(test) 1315 grid_width = base_variant.params.get('grid_width', 1) 1316 if not isinstance(grid_width, int): 1317 raise InvalidTestDefinitionError('"grid_width" must be an integer.') 1318 1319 grids = [_VariantGrid([base_variant], grid_width=grid_width)] 1320 for dimension in _get_variant_dimensions(test): 1321 variants = dimension.variants 1322 if dimension.layout == _VariantLayout.MULTI_FILES: 1323 grids = [ 1324 grid.merge_params(name, params or {}) 1325 for name, params in variants.items() for grid in grids 1326 ] 1327 else: 1328 grids = [grid.add_dimension(variants) for grid in grids] 1329 1330 for grid in grids: 1331 grid.finalize(jinja_env, params_template_loader) 1332 1333 return grids 1334 1335 1336 def _check_uniqueness(tested: DefaultDict[str, Set[_CanvasType]], name: str, 1337 canvas_types: FrozenSet[_CanvasType]) -> None: 1338 already_tested = tested[name].intersection(canvas_types) 1339 if already_tested: 1340 raise InvalidTestDefinitionError( 1341 f'Test {name} is defined twice for types {already_tested}') 1342 tested[name].update(canvas_types) 1343 1344 1345 def _indent_filter(s: str, width: Union[int, str] = 4, 1346 first: bool = False, blank: bool = False) -> str: 1347 """Returns a copy of the string with each line indented by the `width` str. 1348 1349 If `width` is a number, `s` is indented by that number of whitespaces. The 1350 first line and blank lines are not indented by default, unless `first` or 1351 `blank` are `True`, respectively. 1352 1353 This is a re-implementation of the default `indent` Jinja filter, preserving 1354 line ending characters (\r, \n, \f, etc.) The default `indent` Jinja filter 1355 incorrectly replaces all of these characters with newlines.""" 1356 is_first_line = True 1357 def indent_needed(line): 1358 nonlocal first, blank, is_first_line 1359 is_blank = not line.strip() 1360 need_indent = (not is_first_line or first) and (not is_blank or blank) 1361 is_first_line = False 1362 return need_indent 1363 1364 indentation = width if isinstance(width, str) else ' ' * width 1365 return textwrap.indent(s, indentation, indent_needed) 1366 1367 1368 def generate_test_files(name_to_dir_file: str) -> None: 1369 """Generate Canvas tests from YAML file definition.""" 1370 output_dirs = _OutputPaths(element=pathlib.Path('..') / 'element', 1371 offscreen=pathlib.Path('..') / 'offscreen') 1372 1373 params_template_loader = MutableDictLoader() 1374 1375 jinja_env = jinja2.Environment( 1376 loader=jinja2.ChoiceLoader([ 1377 jinja2.PackageLoader('gentest'), 1378 params_template_loader, 1379 ]), 1380 keep_trailing_newline=True, 1381 trim_blocks=True, 1382 lstrip_blocks=True) 1383 1384 jinja_env.filters['double_quote_escape'] = _double_quote_escape 1385 jinja_env.filters['indent'] = _indent_filter 1386 1387 # Run with --test argument to run unit tests. 1388 if len(sys.argv) > 1 and sys.argv[1] == '--test': 1389 doctest = importlib.import_module('doctest') 1390 doctest.testmod() 1391 sys.exit() 1392 1393 name_to_sub_dir = (yaml.safe_load( 1394 pathlib.Path(name_to_dir_file).read_text(encoding='utf-8'))) 1395 1396 tests = [] 1397 test_yaml_directory = 'yaml' 1398 yaml_files = [ 1399 os.path.join(test_yaml_directory, f) 1400 for f in os.listdir(test_yaml_directory) if f.endswith('.yaml') 1401 ] 1402 for t in sum([ 1403 yaml.safe_load(pathlib.Path(f).read_text(encoding='utf-8')) 1404 for f in yaml_files 1405 ], []): 1406 if 'DISABLED' in t: 1407 continue 1408 if 'meta' in t: 1409 eval(compile(t['meta'], '<meta test>', 'exec'), {}, 1410 {'tests': tests}) 1411 else: 1412 tests.append(t) 1413 1414 for sub_dir in set(name_to_sub_dir.values()): 1415 output_dirs.sub_path(sub_dir).mkdir() 1416 1417 used_filenames = collections.defaultdict(set) 1418 used_variants = collections.defaultdict(set) 1419 for test in tests: 1420 print(test['name']) 1421 _check_reserved_params(test) 1422 for grid in _get_variant_grids(test, jinja_env, params_template_loader): 1423 if not grid.enabled: 1424 continue 1425 if test['name'] != grid.file_name: 1426 print(f' {grid.file_name}') 1427 1428 _check_uniqueness(used_filenames, grid.file_name, 1429 grid.canvas_types) 1430 for variant in grid.variants: 1431 _check_uniqueness( 1432 used_variants, 1433 '.'.join([_ensure_rendered(grid.file_name)] + 1434 variant.params['grid_variant_names']), 1435 grid.canvas_types) 1436 1437 sub_dir = _get_test_sub_dir(grid.file_name, name_to_sub_dir) 1438 grid.generate_test(jinja_env, output_dirs.sub_path(sub_dir)) 1439 1440 print() 1441 1442 1443 if __name__ == '__main__': 1444 generate_test_files('name2dir.yaml')