{{ ctx.document.name }}
+{{ attachment.data.decode() if attachment else 'no attachment' }}
diff --git a/packages/dsw-tdk/tests/fixtures/test_render01/src/excluded.html.j2 b/packages/dsw-tdk/tests/fixtures/test_render01/src/excluded.html.j2 new file mode 100644 index 00000000..61daed30 --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render01/src/excluded.html.j2 @@ -0,0 +1 @@ +not uploaded
diff --git a/packages/dsw-tdk/tests/fixtures/test_render01/template.json b/packages/dsw-tdk/tests/fixtures/test_render01/template.json new file mode 100644 index 00000000..86ac5fe6 --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render01/template.json @@ -0,0 +1,50 @@ +{ + "organizationId": "test", + "templateId": "render01", + "version": "1.0.0", + "name": "Test template for rendering", + "description": "Dummy document template for rendering locally", + "metamodelVersion": "18.3", + "language": "en", + "license": "Apache-2.0", + "allowedPackages": [], + "formats": [ + { + "uuid": "d3e98eb6-344d-481f-8e37-6a67b6cd1ad2", + "name": "JSON Data", + "icon": "far fa-file", + "steps": [{"name": "json", "options": {}}] + }, + { + "uuid": "a9293d08-59a4-4e6b-ae62-7a6a570b031c", + "name": "HTML Document", + "icon": "far fa-file-code", + "steps": [ + {"name": "jinja", "options": {"template": "src/document.html.j2", "content-type": "text/html", "extension": "html"}} + ] + }, + { + "uuid": "0f5c8a55-9d2e-4b8e-8e57-7d6c0b7f3e10", + "name": "Project JSON", + "icon": "far fa-file", + "steps": [{"name": "json", "options": {"extras": "project,submissions"}}] + }, + { + "uuid": "6e1b4ee5-4c47-4a2c-9a5b-3cf8b1b6c2a1", + "name": "Excluded", + "icon": "far fa-file-code", + "steps": [ + {"name": "jinja", "options": {"template": "src/excluded.html.j2", "content-type": "text/html", "extension": "html"}} + ] + } + ], + "_tdk": { + "version": "4.34.0", + "readmeFile": "README.md", + "files": [ + "src/**/*", + "assets/**/*", + "!src/excluded.html.j2" + ] + } +} diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/README.md b/packages/dsw-tdk/tests/fixtures/test_render02/README.md new file mode 100644 index 00000000..6523a58a --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/README.md @@ -0,0 +1 @@ +# Test template for rendering options diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/context.json b/packages/dsw-tdk/tests/fixtures/test_render02/context.json new file mode 100644 index 00000000..44782d0c --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/context.json @@ -0,0 +1 @@ +{"config": {"clientUrl": ""}, "document": {"name": "Doc"}} diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/pandoc-filters/mine.lua b/packages/dsw-tdk/tests/fixtures/test_render02/pandoc-filters/mine.lua new file mode 100644 index 00000000..d6ed9661 --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/pandoc-filters/mine.lua @@ -0,0 +1 @@ +-- custom filter diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/pandoc-templates/custom.docx b/packages/dsw-tdk/tests/fixtures/test_render02/pandoc-templates/custom.docx new file mode 100644 index 00000000..8f91b47d --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/pandoc-templates/custom.docx @@ -0,0 +1 @@ +template \ No newline at end of file diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/src/document.html.j2 b/packages/dsw-tdk/tests/fixtures/test_render02/src/document.html.j2 new file mode 100644 index 00000000..8a748730 --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/src/document.html.j2 @@ -0,0 +1 @@ +{{ ctx.document.name }}
diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/src/globals.txt.j2 b/packages/dsw-tdk/tests/fixtures/test_render02/src/globals.txt.j2 new file mode 100644 index 00000000..661a75c4 --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/src/globals.txt.j2 @@ -0,0 +1 @@ +{{ secrets.token if secrets is defined else "no secrets" }}|{{ "requests" if requests is defined else "no requests" }} diff --git a/packages/dsw-tdk/tests/fixtures/test_render02/template.json b/packages/dsw-tdk/tests/fixtures/test_render02/template.json new file mode 100644 index 00000000..0390d542 --- /dev/null +++ b/packages/dsw-tdk/tests/fixtures/test_render02/template.json @@ -0,0 +1,35 @@ +{ + "organizationId": "test", + "templateId": "render02", + "version": "1.0.0", + "name": "Test template for rendering options", + "description": "Dummy document template for secrets, requests and pandoc", + "metamodelVersion": "18.3", + "language": "en", + "license": "Apache-2.0", + "allowedPackages": [], + "formats": [ + { + "uuid": "b5a1d3f2-3f9e-4f5b-9a37-2a1a4c9b8d01", + "name": "Globals", + "icon": "far fa-file-code", + "steps": [ + {"name": "jinja", "options": {"template": "src/globals.txt.j2", "content-type": "text/plain", "extension": "txt"}} + ] + }, + { + "uuid": "b5a1d3f2-3f9e-4f5b-9a37-2a1a4c9b8d02", + "name": "Word", + "icon": "far fa-file-word", + "steps": [ + {"name": "jinja", "options": {"template": "src/document.html.j2"}}, + {"name": "pandoc", "options": {"from": "html", "to": "docx", "template": "custom.docx", "filters": "docx-pagebreak.lua,mine.lua"}} + ] + } + ], + "_tdk": { + "version": "4.34.0", + "readmeFile": "README.md", + "files": ["src/**/*"] + } +} diff --git a/packages/dsw-tdk/tests/test_cmd_render.py b/packages/dsw-tdk/tests/test_cmd_render.py new file mode 100644 index 00000000..8b95278a --- /dev/null +++ b/packages/dsw-tdk/tests/test_cmd_render.py @@ -0,0 +1,166 @@ +# cspell:ignore Plán správy +import json +import pathlib + +import click.testing + +from dsw.tdk import main + + +def render(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, *args: str): + template_path = fixtures_path / 'test_render01' + runner = click.testing.CliRunner() + return runner.invoke(main, args=[ + 'render', template_path.as_posix(), + '--context', (template_path / 'context.json').as_posix(), + *args, + ]) + + +def test_render_json(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.json' + result = render(fixtures_path, tmp_path, '--format', 'JSON Data', '-o', output.as_posix()) + assert result.exit_code == 0, result.output + assert 'rendered (application/json' in result.output + assert json.loads(output.read_text(encoding='utf-8'))['document']['name'] == 'My Plan' + + +def test_render_adds_worker_defaults(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.json' + result = render(fixtures_path, tmp_path, '--format', 'JSON Data', '-o', output.as_posix()) + assert result.exit_code == 0, result.output + context = json.loads(output.read_text(encoding='utf-8')) + assert context['config']['serviceName'] == 'Data Stewardship Wizard' + assert context['config']['appTitle'] == 'My Wizard' # kept from the context + assert context['config']['primaryColor'] == '#0033aa' # default for a missing one + assert context['config']['logoUrl'] == 'https://wizard.example.org/assets/logo.svg' + assert context['extras'] == {} + + +def test_render_fills_requested_extras(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.json' + result = render(fixtures_path, tmp_path, '--format', 'Project JSON', '-o', output.as_posix()) + assert result.exit_code == 0, result.output + assert 'Extra "project" is not in the context' in result.output + assert 'Extra "submissions" is not in the context' in result.output + context = json.loads(output.read_text(encoding='utf-8')) + assert context['extras'] == {'project': None, 'submissions': []} + + +def test_render_html_by_uuid_with_translations_and_project_files( + fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + template_path = fixtures_path / 'test_render01' + output = tmp_path / 'out.html' + result = render( + fixtures_path, tmp_path, + '--format', 'a9293d08-59a4-4e6b-ae62-7a6a570b031c', + '--po', (template_path / 'cs.po').as_posix(), + '--project-files', (template_path / 'project-files').as_posix(), + '-o', output.as_posix(), + ) + assert result.exit_code == 0, result.output + content = output.read_text(encoding='utf-8') + assert 'My Plan
' in content + assert 'src="data:image/png;base64,' in content + assert 'Attached notes
' in content + + +def test_render_html_without_translations_and_project_files( + fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.html' + result = render(fixtures_path, tmp_path, '--format', 'html document', '-o', output.as_posix()) + assert result.exit_code == 0, result.output + content = output.read_text(encoding='utf-8') + assert 'no attachment
' in content + assert 'WARNING' in result.output # the project file cannot be fetched + + +def test_render_default_output_name(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, + monkeypatch): + monkeypatch.chdir(tmp_path) + result = render(fixtures_path, tmp_path, '--format', 'JSON Data') + assert result.exit_code == 0, result.output + assert (tmp_path / 'render01.json').is_file() + + +def test_render_existing_output_without_force(fixtures_path: pathlib.Path, + tmp_path: pathlib.Path): + output = tmp_path / 'out.json' + output.write_text('original', encoding='utf-8') + result = render(fixtures_path, tmp_path, '--format', 'JSON Data', '-o', output.as_posix()) + assert result.exit_code == 1 + assert 'already exists' in result.output + assert output.read_text(encoding='utf-8') == 'original' + result = render(fixtures_path, tmp_path, '--format', 'JSON Data', '-o', output.as_posix(), + '--force') + assert result.exit_code == 0, result.output + + +def test_render_requires_format_choice(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + result = render(fixtures_path, tmp_path) + assert result.exit_code == 1 + assert 'choose one of: "JSON Data", "HTML Document", "Project JSON", "Excluded"' \ + in result.output + + +def test_render_unknown_format(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + result = render(fixtures_path, tmp_path, '--format', 'PDF') + assert result.exit_code == 1 + assert 'Format "PDF" not found' in result.output + + +def test_render_uses_only_template_files(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + # src/excluded.html.j2 is not in _tdk.files, so the server would not have it either + result = render(fixtures_path, tmp_path, '--format', 'Excluded', + '-o', (tmp_path / 'out.html').as_posix()) + assert result.exit_code == 1 + assert 'Failed to render the document' in result.output + assert 'src/excluded.html.j2' in result.output + + +def test_render_context_defaults_override(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, + monkeypatch): + monkeypatch.setenv('DOCUMENT_CONTEXT_SERVICE_URL', 'https://fair-wizard.com') + monkeypatch.setenv('DOCUMENT_CONTEXT_SERVICE_NAME', 'From environment') + output = tmp_path / 'out.json' + result = render(fixtures_path, tmp_path, '--format', 'JSON Data', '-o', output.as_posix(), + '-D', 'serviceName=FAIR Wizard', + '--context-default', 'defaultPrimaryColor=#123456', + '-D', 'defaultAppTitle=FW') + assert result.exit_code == 0, result.output + config = json.loads(output.read_text(encoding='utf-8'))['config'] + assert config['serviceName'] == 'FAIR Wizard' # option wins over the environment + assert config['serviceUrl'] == 'https://fair-wizard.com' # from the environment + assert config['primaryColor'] == '#123456' # missing in the context + assert config['appTitle'] == 'My Wizard' # the context wins over a default + assert config['serviceNameShort'] == 'DSW' # untouched default + + +def test_render_context_defaults_from_dot_env(fixtures_path: pathlib.Path, + tmp_path: pathlib.Path, monkeypatch): + monkeypatch.delenv('DOCUMENT_CONTEXT_SERVICE_NAME', raising=False) + dot_env = tmp_path / '.env' + dot_env.write_text('DOCUMENT_CONTEXT_SERVICE_NAME=Dot Env Wizard\n', encoding='utf-8') + template_path = fixtures_path / 'test_render01' + output = tmp_path / 'out.json' + result = click.testing.CliRunner().invoke(main, args=[ + '--dot-env', dot_env.as_posix(), '--no-config', + 'render', template_path.as_posix(), '-c', (template_path / 'context.json').as_posix(), + '-F', 'JSON Data', '-o', output.as_posix(), + ]) + monkeypatch.delenv('DOCUMENT_CONTEXT_SERVICE_NAME', raising=False) # set by load_dotenv + assert result.exit_code == 0, result.output + config = json.loads(output.read_text(encoding='utf-8'))['config'] + assert config['serviceName'] == 'Dot Env Wizard' + + +def test_render_context_defaults_invalid(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + result = render(fixtures_path, tmp_path, '-F', 'JSON Data', '-D', 'serviceName') + assert result.exit_code == 2 + assert '"serviceName" is not NAME=VALUE' in result.output + result = render(fixtures_path, tmp_path, '-F', 'JSON Data', '-D', 'appTitle=X') + assert result.exit_code == 2 + assert 'Unknown context default appTitle' in result.output + assert 'defaultAppTitle' in result.output diff --git a/packages/dsw-tdk/tests/test_cmd_render_options.py b/packages/dsw-tdk/tests/test_cmd_render_options.py new file mode 100644 index 00000000..81104631 --- /dev/null +++ b/packages/dsw-tdk/tests/test_cmd_render_options.py @@ -0,0 +1,80 @@ +import importlib.util +import pathlib + +import click.testing + +from dsw.tdk import main + + +def render(fixtures_path: pathlib.Path, output: pathlib.Path, *args: str): + template_path = fixtures_path / 'test_render02' + return click.testing.CliRunner().invoke(main, args=[ + '--no-config', '--no-dot-env', + 'render', template_path.as_posix(), + '--context', (template_path / 'context.json').as_posix(), + '-o', output.as_posix(), '--force', + *args, + ]) + + +def test_no_globals_by_default(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.txt' + result = render(fixtures_path, output, '-F', 'Globals') + assert result.exit_code == 0, result.output + assert output.read_text(encoding='utf-8').strip() == 'no secrets|no requests' + + +def test_secrets_and_requests(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.txt' + result = render(fixtures_path, output, '-F', 'Globals', + '--secret', 'token=abc=123', '--allow-requests') + if importlib.util.find_spec('requests') is None: # without dsw-tdk[all] + assert result.exit_code == 1 + assert 'install dsw-templating[http]' in result.output + return + assert result.exit_code == 0, result.output + assert output.read_text(encoding='utf-8').strip() == 'abc=123|requests' + + +def test_secret_without_requests(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + output = tmp_path / 'out.txt' + result = render(fixtures_path, output, '-F', 'Globals', '-s', 'token=x') + assert result.exit_code == 0, result.output + assert output.read_text(encoding='utf-8').strip() == 'x|no requests' + + +def test_invalid_secret(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + result = render(fixtures_path, tmp_path / 'out.txt', '-F', 'Globals', '-s', 'token') + assert result.exit_code == 2 + assert '"token" is not NAME=VALUE' in result.output + + +def test_pandoc_needs_templates_and_filters(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, + monkeypatch): + monkeypatch.delenv('PANDOC_TEMPLATES', raising=False) + monkeypatch.delenv('PANDOC_FILTERS', raising=False) + result = render(fixtures_path, tmp_path / 'out.docx', '-F', 'Word') + assert result.exit_code == 1 + assert 'Pandoc filter "mine.lua" not found' in result.output + + +def test_pandoc_with_templates_and_filters(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, + monkeypatch): + monkeypatch.setenv('PATH', str(tmp_path)) # no pandoc, whatever is installed + template_path = fixtures_path / 'test_render02' + monkeypatch.setenv('PANDOC_TEMPLATES', (template_path / 'pandoc-templates').as_posix()) + result = render(fixtures_path, tmp_path / 'out.docx', '-F', 'Word', + '--pandoc-filters', (template_path / 'pandoc-filters').as_posix()) + assert result.exit_code == 1 + # templates and filters were found, it got as far as running pandoc + assert 'Executable "pandoc" not found, is it installed?' in result.output + + +def test_pandoc_template_missing(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, + monkeypatch): + template_path = fixtures_path / 'test_render02' + monkeypatch.delenv('PANDOC_TEMPLATES', raising=False) + result = render(fixtures_path, tmp_path / 'out.docx', '-F', 'Word', + '--pandoc-filters', (template_path / 'pandoc-filters').as_posix()) + assert result.exit_code == 1 + assert 'Pandoc template "custom.docx" not found' in result.output diff --git a/packages/dsw-tdk/tests/test_render_stderr.py b/packages/dsw-tdk/tests/test_render_stderr.py new file mode 100644 index 00000000..cd6c6f86 --- /dev/null +++ b/packages/dsw-tdk/tests/test_render_stderr.py @@ -0,0 +1,33 @@ +import os + +from dsw.tdk.render import filtered_native_stderr + + +NOISE = ( + b'Fontconfig error: the ambiguous constant name: normal: ' + b'Use :02. 03. 2026
+Plan with bold text and a link.
+ +notes.txt: Project file content (text/plain)
+Enriched by a plugin
+ + \ No newline at end of file diff --git a/packages/dsw-templating/tests/fixtures/golden/document.json b/packages/dsw-templating/tests/fixtures/golden/document.json new file mode 100644 index 00000000..75ceeacc --- /dev/null +++ b/packages/dsw-templating/tests/fixtures/golden/document.json @@ -0,0 +1,32 @@ +{ + "document": { + "createdAt": "2026-03-01T23:30:00Z", + "language": "cs", + "name": "Golden Plan" + }, + "entries": [ + { + "size": 1536000, + "title": "Datasets" + }, + { + "size": null, + "title": "Software." + }, + { + "size": 42, + "title": "Publications" + } + ], + "extras": { + "plugin": "Enriched by a plugin" + }, + "files": [ + { + "contentType": "text/plain", + "fileName": "notes.txt", + "uuid": "22222222-0000-0000-0000-000000000001" + } + ], + "summary": "Plan with **bold** text and a [link](https://example.org).\n\n" +} \ No newline at end of file diff --git a/packages/dsw-templating/tests/fixtures/golden/sharedStrings.xml b/packages/dsw-templating/tests/fixtures/golden/sharedStrings.xml new file mode 100644 index 00000000..f8e5fa2e --- /dev/null +++ b/packages/dsw-templating/tests/fixtures/golden/sharedStrings.xml @@ -0,0 +1,2 @@ + +{{ ctx.document.createdAt|datetime_format('%d. %m. %Y', 'Europe/Prague') }}
+{{ attachment.name }}: {{ attachment.data.decode('utf-8') }} ({{ attachment.content_type }})
{% endif %} +{{ ctx.extras.plugin }}
+ + diff --git a/packages/dsw-templating/tests/fixtures/template/src/sheet.json.j2 b/packages/dsw-templating/tests/fixtures/template/src/sheet.json.j2 new file mode 100644 index 00000000..fae756b7 --- /dev/null +++ b/packages/dsw-templating/tests/fixtures/template/src/sheet.json.j2 @@ -0,0 +1,15 @@ +{ + "properties": {"document": {"title": {{ ctx.document.name|tojson }}, "created": "2026-01-01"}}, + "formats": {"bold": {"bold": true}}, + "sheets": [ + { + "name": "Items", + "data": [ + {"type": "row", "cell": "A1", "data": ["Title", "Size"], "format": "bold"}, + {%- for item in ctx.entries %} + {"type": "row", "row": {{ loop.index }}, "col": 0, "data": [{{ item.title|tojson }}, {{ item.size|tojson }}]}{{ "," if not loop.last }} + {%- endfor %} + ] + } + ] +} diff --git a/packages/dsw-templating/tests/fixtures/template/template.json b/packages/dsw-templating/tests/fixtures/template/template.json new file mode 100644 index 00000000..e5b3c931 --- /dev/null +++ b/packages/dsw-templating/tests/fixtures/template/template.json @@ -0,0 +1,65 @@ +{ + "organizationId": "dsw", + "templateId": "golden", + "version": "1.0.0", + "name": "Golden Fixture", + "description": "Small template exercising the built-in steps", + "license": "Apache-2.0", + "metamodelVersion": "18.3", + "allowedPackages": [], + "formats": [ + { + "uuid": "11111111-0000-0000-0000-000000000001", + "name": "JSON Data", + "icon": "far fa-file", + "steps": [ + {"name": "json", "options": {}} + ] + }, + { + "uuid": "11111111-0000-0000-0000-000000000002", + "name": "HTML Document", + "icon": "far fa-file-code", + "steps": [ + {"name": "jinja", "options": {"template": "src/document.html.j2", "content-type": "text/html", "extension": "html"}} + ] + }, + { + "uuid": "11111111-0000-0000-0000-000000000003", + "name": "Zipped HTML", + "icon": "far fa-file-archive", + "steps": [ + {"name": "jinja", "options": {"template": "src/document.html.j2", "content-type": "text/html", "extension": "html"}}, + {"name": "archive", "options": {"type": "zip", "compression": "gzip", "inputFileDst": "report.html"}} + ] + }, + { + "uuid": "11111111-0000-0000-0000-000000000004", + "name": "Excel", + "icon": "far fa-file-excel", + "steps": [ + {"name": "jinja", "options": {"template": "src/sheet.json.j2", "content-type": "application/json", "extension": "json"}}, + {"name": "excel", "options": {}} + ] + }, + { + "uuid": "11111111-0000-0000-0000-000000000005", + "name": "PDF Document", + "icon": "far fa-file-pdf", + "steps": [ + {"name": "jinja", "options": {"template": "src/document.html.j2", "content-type": "text/html", "extension": "html"}}, + {"name": "weasyprint", "options": {}} + ] + }, + { + "uuid": "11111111-0000-0000-0000-000000000006", + "name": "MS Word Document", + "icon": "far fa-file-word", + "steps": [ + {"name": "jinja", "options": {"template": "src/document.html.j2", "content-type": "text/html", "extension": "html"}}, + {"name": "pandoc", "options": {"from": "html", "to": "docx", "filters": "docx-pagebreak.lua"}}, + {"name": "enrich-docx", "options": {"rewrite:docProps/custom.xml": "render:src/custom.xml.j2"}} + ] + } + ] +} diff --git a/packages/dsw-templating/tests/test_context.py b/packages/dsw-templating/tests/test_context.py new file mode 100644 index 00000000..57528496 --- /dev/null +++ b/packages/dsw-templating/tests/test_context.py @@ -0,0 +1,42 @@ +import pytest + +from dsw.templating import ContextDefaults, enrich_context_config + + +def test_defaults_fill_missing_branding(): + context = {'config': {'clientUrl': 'https://fw.example.org/', 'appTitle': None}} + enrich_context_config(context, ContextDefaults()) + assert context['config'] == { + 'clientUrl': 'https://fw.example.org/', + 'serviceName': 'Data Stewardship Wizard', + 'serviceNameShort': 'DSW', + 'serviceUrl': 'https://ds-wizard.org', + 'serviceDomainName': 'ds-wizard.org', + 'appTitle': 'DS Wizard', + 'appTitleShort': 'DS Wizard', + 'primaryColor': '#0033aa', + 'illustrationsColor': '#0033aa', + 'logoUrl': 'https://fw.example.org/assets/logo.svg', + } + + +def test_branding_is_kept_and_service_is_set(): + context = {'config': { + 'clientUrl': '', 'appTitle': 'My Wizard', 'appTitleShort': 'MW', + 'primaryColor': '#ff0000', 'illustrationsColor': '#00ff00', + 'logoUrl': 'https://cdn.example.org/logo.png', 'serviceName': 'Stale', + }} + enrich_context_config(context, ContextDefaults(service_name='FAIR Wizard')) + config = context['config'] + assert config['appTitle'] == 'My Wizard' + assert config['appTitleShort'] == 'MW' + assert config['primaryColor'] == '#ff0000' + assert config['illustrationsColor'] == '#00ff00' + assert config['logoUrl'] == 'https://cdn.example.org/logo.png' + assert config['serviceName'] == 'FAIR Wizard' + + +def test_context_without_config_is_rejected(): + # as in the document worker: the server always sends it + with pytest.raises(KeyError): + enrich_context_config({}, ContextDefaults()) diff --git a/packages/dsw-document-worker/tests/test_context_reference.py b/packages/dsw-templating/tests/test_context_reference.py similarity index 99% rename from packages/dsw-document-worker/tests/test_context_reference.py rename to packages/dsw-templating/tests/test_context_reference.py index 59d038f6..0e691972 100644 --- a/packages/dsw-document-worker/tests/test_context_reference.py +++ b/packages/dsw-templating/tests/test_context_reference.py @@ -11,7 +11,7 @@ import pathlib import uuid -from dsw.document_worker.templates.filters import to_context_obj +from dsw.templating.filters import to_context_obj from dsw.models.knowledge_model import graph as km_graph from dsw.models.knowledge_model.bundle import compile_bundle from dsw.models.knowledge_model.package import KnowledgeModelBundle diff --git a/packages/dsw-templating/tests/test_extras.py b/packages/dsw-templating/tests/test_extras.py new file mode 100644 index 00000000..5c2ed642 --- /dev/null +++ b/packages/dsw-templating/tests/test_extras.py @@ -0,0 +1,86 @@ +"""Optional dependencies: importing works without them, using a step does not.""" +import pathlib +import subprocess +import sys + +import pytest + +from dsw.templating import MissingExtraError, TemplateError + + +EXTRA_MODULES = ('panflute', 'rdflib', 'requests', 'weasyprint', 'xlsxwriter') + +IMPORT_ALL = f""" +import importlib, pkgutil, sys +for name in {EXTRA_MODULES!r}: + sys.modules[name] = None # makes any import of it fail +import dsw.templating +for mod in pkgutil.walk_packages(dsw.templating.__path__, 'dsw.templating.'): + importlib.import_module(mod.name) +leaked = sorted(name for name in {EXTRA_MODULES!r} if sys.modules.get(name) is not None) +assert not leaked, leaked +print('ok') +""" + + +def block(monkeypatch, *modules: str): + for module in modules: + monkeypatch.setitem(sys.modules, module, None) + + +def jinja_format(template: str, *steps: dict) -> dict: + return { + 'uuid': 'f', + 'name': 'Format', + 'steps': [ + {'name': 'jinja', 'options': {'template': template}}, + *steps, + ], + } + + +@pytest.fixture +def template_dir(tmp_path: pathlib.Path) -> pathlib.Path: + (tmp_path / 'root.j2').write_text('{{ rdflib.Graph() }}', encoding='utf-8') + return tmp_path + + +def test_import_without_extras(): + result = subprocess.run( + [sys.executable, '-c', IMPORT_ALL], + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == 'ok' + + +@pytest.mark.parametrize(('step', 'options', 'module', 'extra'), [ + ('weasyprint', {}, 'weasyprint', 'pdf'), + ('excel', {}, 'xlsxwriter', 'excel'), + ('rdflib-convert', {'from': 'turtle', 'to': 'jsonld'}, 'rdflib', 'rdf'), +]) +def test_step_without_extra_fails_clearly(monkeypatch, make_template, template_dir, + step, options, module, extra): + block(monkeypatch, module) + template = make_template(template_dir, formats=[ + jinja_format('root.j2', {'name': step, 'options': options}), + ]) + with pytest.raises(TemplateError, match=rf'install dsw-templating\[{extra}\]'): + template.prepare_format('f') + + +def test_requests_without_extra_fails_clearly(monkeypatch, make_template, template_dir): + from dsw.templating import RenderSettings, RequestsSettings, TemplateSettings + + block(monkeypatch, 'requests') + settings = RenderSettings(template=TemplateSettings(requests=RequestsSettings(enabled=True))) + template = make_template(template_dir, formats=[jinja_format('root.j2')], settings=settings) + with pytest.raises(TemplateError, match=r'install dsw-templating\[http\]'): + template.prepare_format('f') + + +def test_rdflib_global_without_extra_fails_when_used(monkeypatch, make_template, template_dir): + block(monkeypatch, 'rdflib') + template = make_template(template_dir, formats=[jinja_format('root.j2')]) + with pytest.raises(MissingExtraError, match=r'install dsw-templating\[rdf\]'): + template.render('f', {}) diff --git a/packages/dsw-templating/tests/test_golden.py b/packages/dsw-templating/tests/test_golden.py new file mode 100644 index 00000000..5b0a86f2 --- /dev/null +++ b/packages/dsw-templating/tests/test_golden.py @@ -0,0 +1,161 @@ +# cspell:ignore Plán správy +"""Golden outputs of a small fixture template, one per built-in step chain. + +Deterministic outputs are compared byte for byte with ``fixtures/golden``; for +archives and spreadsheets the relevant members are compared instead, as the +containers carry timestamps (and the line endings of XML members are normalized). Run with ``UPDATE_GOLDEN=1`` to rewrite them. +""" +import gettext +import io +import json +import os +import pathlib +import shutil +import zipfile + +import polib +import pytest + +from dsw.templating import ( + FileFormats, + RenderContext, + RenderSettings, + Template, + create_manager, + hookimpl, +) + + +FIXTURES = pathlib.Path(__file__).parent / 'fixtures' +GOLDEN = FIXTURES / 'golden' +UPDATE = os.getenv('UPDATE_GOLDEN') == '1' + +FORMAT_JSON = '11111111-0000-0000-0000-000000000001' +FORMAT_HTML = '11111111-0000-0000-0000-000000000002' +FORMAT_ZIP = '11111111-0000-0000-0000-000000000003' +FORMAT_XLSX = '11111111-0000-0000-0000-000000000004' +FORMAT_PDF = '11111111-0000-0000-0000-000000000005' +FORMAT_DOCX = '11111111-0000-0000-0000-000000000006' + + +class EnrichingPlugin: + + @hookimpl + def enrich_document_context(self, context: dict) -> None: + context.setdefault('extras', {})['plugin'] = 'Enriched by a plugin' + + +class FakeProjectFiles: + + def __init__(self): + self.requested: list[str] = [] + + def resolve(self, file_uuid: str, name: str, content_type: str) -> bytes | None: + self.requested.append(file_uuid) + return b'Project file content' + + +def assert_golden(name: str, data: bytes): + path = GOLDEN / name + if name.endswith('.xml'): + # XlsxWriter ends the XML declaration with the platform line separator + data = data.replace(b'\r\n', b'\n') + if UPDATE: + path.write_bytes(data) + assert data == path.read_bytes(), f'{name} differs from its golden file' + + +@pytest.fixture +def template(tmp_path: pathlib.Path) -> Template: + template_dir = tmp_path / 'template' + shutil.copytree(FIXTURES / 'template', template_dir) + plugins = create_manager() + plugins.register(EnrichingPlugin()) + return Template.from_directory(template_dir, settings=RenderSettings(), plugins=plugins) + + +@pytest.fixture +def context() -> dict: + return json.loads((FIXTURES / 'context.json').read_text(encoding='utf-8')) + + +@pytest.fixture +def render_ctx(tmp_path: pathlib.Path) -> RenderContext: + po = polib.POFile() + po.metadata = {'Content-Type': 'text/plain; charset=utf-8', 'Language': 'cs'} + po.append(polib.POEntry(msgid='Data Management Plan', msgstr='Plán správy dat')) + mo_path = tmp_path / 'messages.mo' + po.save_as_mofile(str(mo_path)) + with mo_path.open('rb') as fp: + return RenderContext(translations=gettext.GNUTranslations(fp), language='cs') + + +def test_template_from_directory(template): + assert template.coordinates == 'dsw:golden:1.0.0' + assert template.has_format(FORMAT_HTML) + asset = template.fetch_asset('assets/logo.png') + assert asset is not None + assert asset.content_type == 'image/png' + assert template.fetch_asset('assets/missing.png') is None + + +def test_json(template, context): + document = template.render(FORMAT_JSON, context) + assert document.file_format == FileFormats.JSON + assert document.encoding == 'utf-8' + assert_golden('document.json', document.content) + + +def test_html(template, context, render_ctx): + project_files = FakeProjectFiles() + document = template.render(FORMAT_HTML, context, + render_ctx=render_ctx, project_files=project_files) + assert document.file_format == FileFormats.HTML + assert project_files.requested == ['22222222-0000-0000-0000-000000000001'] + assert_golden('document.html', document.content) + + +def test_html_without_translations_and_project_files(template, context): + content = template.render(FORMAT_HTML, context).content.decode('utf-8') + assert '{{ ctx.name }}|{{ content }}
', + encoding='utf-8') + (tmp_path / 'static.xml').write_text('N|<old/>
' + assert docx.read('word/extra.xml') == b'