"""Tests for pbi_cli.core.report_backend. Covers all public functions: report_info, report_create, report_validate, page_list, page_add, page_delete, page_get, and theme_set. A ``sample_report`` fixture builds a minimal valid PBIR folder in tmp_path so every test starts from a consistent, known-good state. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from pbi_cli.core.errors import PbiCliError from pbi_cli.core.report_backend import ( page_add, page_delete, page_get, page_list, page_set_background, page_set_visibility, report_create, report_info, report_validate, theme_diff, theme_get, theme_set, ) # --------------------------------------------------------------------------- # Schema constants (mirrors pbir_models.py -- used only for fixture JSON) # --------------------------------------------------------------------------- _SCHEMA_REPORT = ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definition/report/1.0.0/schema.json" ) _SCHEMA_PAGE = ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definition/page/1.0.0/schema.json" ) _SCHEMA_PAGES_METADATA = ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definition/pagesMetadata/1.0.0/schema.json" ) _SCHEMA_VERSION = ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definition/versionMetadata/1.0.0/schema.json" ) _SCHEMA_VISUAL_CONTAINER = ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definition/visualContainer/2.7.0/schema.json" ) _SCHEMA_VISUAL_CONFIG = ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definition/visualConfiguration/2.3.0/schema.json" ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _write(path: Path, data: dict[str, Any]) -> None: """Write a dict as formatted JSON.""" path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") def _read(path: Path) -> dict[str, Any]: """Read and parse a JSON file.""" return json.loads(path.read_text(encoding="utf-8")) # type: ignore[return-value] def _make_page( pages_dir: Path, page_name: str, display_name: str, ordinal: int = 0, with_visual: bool = True, ) -> None: """Create a minimal page folder inside *pages_dir*.""" page_dir = pages_dir / page_name page_dir.mkdir(parents=True, exist_ok=True) _write( page_dir / "page.json", { "$schema": _SCHEMA_PAGE, "name": page_name, "displayName": display_name, "displayOption": "FitToPage", "width": 1280, "height": 720, "ordinal": ordinal, }, ) visuals_dir = page_dir / "visuals" visuals_dir.mkdir(exist_ok=True) if with_visual: visual_dir = visuals_dir / "visual_def456" visual_dir.mkdir() _write( visual_dir / "visual.json", { "$schema": _SCHEMA_VISUAL_CONTAINER, "name": "vis1", "position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0, "tabOrder": 0}, "visual": { "$schema": _SCHEMA_VISUAL_CONFIG, "visualType": "barChart", "query": { "queryState": { "Category": {"projections": []}, "Y": {"projections": []}, }, }, "objects": {}, }, }, ) # --------------------------------------------------------------------------- # Fixture # --------------------------------------------------------------------------- @pytest.fixture() def sample_report(tmp_path: Path) -> Path: """Build a minimal valid PBIR folder and return the *definition* path. Layout:: MyReport.Report/ definition.pbir definition/ version.json report.json pages/ pages.json page_abc123/ page.json visuals/ visual_def456/ visual.json """ report_folder = tmp_path / "MyReport.Report" definition_dir = report_folder / "definition" pages_dir = definition_dir / "pages" pages_dir.mkdir(parents=True) # version.json _write( definition_dir / "version.json", { "$schema": _SCHEMA_VERSION, "version": "1.0.0", }, ) # report.json _write( definition_dir / "report.json", { "$schema": _SCHEMA_REPORT, "themeCollection": { "baseTheme": { "name": "CY24SU06", "reportVersionAtImport": "5.55", "type": "SharedResources", }, }, "layoutOptimization": "Disabled", }, ) # pages.json _write( pages_dir / "pages.json", { "$schema": _SCHEMA_PAGES_METADATA, "pageOrder": ["page1"], }, ) # definition.pbir _write( report_folder / "definition.pbir", { "$schema": ( "https://developer.microsoft.com/json-schemas/" "fabric/item/report/definitionProperties/2.0.0/schema.json" ), "version": "4.0", }, ) # Page with one visual _make_page(pages_dir, "page1", "Page One", ordinal=0, with_visual=True) return definition_dir # --------------------------------------------------------------------------- # report_info # --------------------------------------------------------------------------- class TestReportInfo: def test_report_info_returns_page_count(self, sample_report: Path) -> None: result = report_info(sample_report) assert result["page_count"] == 1 def test_report_info_returns_theme(self, sample_report: Path) -> None: result = report_info(sample_report) assert result["theme"] == "CY24SU06" def test_report_info_counts_visuals(self, sample_report: Path) -> None: result = report_info(sample_report) assert result["total_visuals"] == 1 def test_report_info_pages_structure(self, sample_report: Path) -> None: result = report_info(sample_report) pages = result["pages"] assert len(pages) == 1 page = pages[0] assert page["name"] == "page1" assert page["display_name"] == "Page One" assert page["ordinal"] == 0 assert page["visual_count"] == 1 def test_report_info_includes_path(self, sample_report: Path) -> None: result = report_info(sample_report) assert "path" in result assert str(sample_report) in result["path"] def test_report_info_empty_report(self, tmp_path: Path) -> None: """A report with no pages directory returns zero counts.""" definition_dir = tmp_path / "Empty.Report" / "definition" definition_dir.mkdir(parents=True) _write( definition_dir / "report.json", { "$schema": _SCHEMA_REPORT, "themeCollection": { "baseTheme": { "name": "CY24SU06", "reportVersionAtImport": "5.55", "type": "SharedResources", }, }, "layoutOptimization": "Disabled", }, ) result = report_info(definition_dir) assert result["page_count"] == 0 assert result["total_visuals"] == 0 assert result["pages"] == [] def test_report_info_multiple_pages(self, sample_report: Path) -> None: """Adding a second page updates page_count and total_visuals.""" pages_dir = sample_report / "pages" # Second page with no visual _make_page(pages_dir, "page2", "Page Two", ordinal=1, with_visual=False) pages_meta = pages_dir / "pages.json" meta = _read(pages_meta) meta["pageOrder"] = ["page1", "page2"] _write(pages_meta, meta) result = report_info(sample_report) assert result["page_count"] == 2 # page1 has 1 visual, page2 has 0 assert result["total_visuals"] == 1 def test_report_info_default_theme_when_missing(self, tmp_path: Path) -> None: """Returns 'Default' when themeCollection is absent from report.json.""" definition_dir = tmp_path / "Bare.Report" / "definition" definition_dir.mkdir(parents=True) _write( definition_dir / "report.json", { "$schema": _SCHEMA_REPORT, "layoutOptimization": "Disabled", }, ) result = report_info(definition_dir) assert result["theme"] == "Default" # --------------------------------------------------------------------------- # report_create # --------------------------------------------------------------------------- class TestReportCreate: def test_report_create_returns_created_status(self, tmp_path: Path) -> None: result = report_create(tmp_path, "SalesReport") assert result["status"] == "created" assert result["name"] == "SalesReport" def test_report_create_report_folder_exists(self, tmp_path: Path) -> None: report_create(tmp_path, "SalesReport") assert (tmp_path / "SalesReport.Report").is_dir() def test_report_create_version_json_exists(self, tmp_path: Path) -> None: report_create(tmp_path, "SalesReport") version_file = tmp_path / "SalesReport.Report" / "definition" / "version.json" assert version_file.exists() data = _read(version_file) assert data["version"] == "2.0.0" def test_report_create_report_json_exists(self, tmp_path: Path) -> None: report_create(tmp_path, "SalesReport") report_json = tmp_path / "SalesReport.Report" / "definition" / "report.json" assert report_json.exists() data = _read(report_json) assert "themeCollection" in data assert "layoutOptimization" in data def test_report_create_pages_json_exists(self, tmp_path: Path) -> None: report_create(tmp_path, "SalesReport") pages_json = tmp_path / "SalesReport.Report" / "definition" / "pages" / "pages.json" assert pages_json.exists() data = _read(pages_json) assert data["pageOrder"] == [] def test_report_create_definition_pbir_exists(self, tmp_path: Path) -> None: report_create(tmp_path, "SalesReport") pbir_file = tmp_path / "SalesReport.Report" / "definition.pbir" assert pbir_file.exists() def test_report_create_pbip_file_exists(self, tmp_path: Path) -> None: report_create(tmp_path, "SalesReport") pbip_file = tmp_path / "SalesReport.pbip" assert pbip_file.exists() data = _read(pbip_file) assert data["version"] == "1.0" assert any( a.get("report", {}).get("path") == "SalesReport.Report" for a in data["artifacts"] ) def test_report_create_returns_definition_path(self, tmp_path: Path) -> None: result = report_create(tmp_path, "SalesReport") expected = str(tmp_path / "SalesReport.Report" / "definition") assert result["definition_path"] == expected def test_report_create_with_dataset(self, tmp_path: Path) -> None: """definition.pbir must include a datasetReference when dataset_path given.""" result = report_create(tmp_path, "SalesReport", dataset_path="../SalesModel.Dataset") assert result["status"] == "created" pbir_file = tmp_path / "SalesReport.Report" / "definition.pbir" data = _read(pbir_file) assert "datasetReference" in data assert data["datasetReference"]["byPath"]["path"] == "../SalesModel.Dataset" def test_report_create_without_dataset_scaffolds_semantic_model(self, tmp_path: Path) -> None: """When no dataset path given, a blank semantic model is scaffolded.""" report_create(tmp_path, "EmptyReport") pbir_file = tmp_path / "EmptyReport.Report" / "definition.pbir" data = _read(pbir_file) assert "datasetReference" in data assert data["datasetReference"]["byPath"]["path"] == "../EmptyReport.SemanticModel" # Semantic model files exist assert (tmp_path / "EmptyReport.SemanticModel" / "definition" / "model.tmdl").exists() assert (tmp_path / "EmptyReport.SemanticModel" / ".platform").exists() assert (tmp_path / "EmptyReport.SemanticModel" / "definition.pbism").exists() # --------------------------------------------------------------------------- # report_validate # --------------------------------------------------------------------------- class TestReportValidate: def test_report_validate_valid_report(self, sample_report: Path) -> None: result = report_validate(sample_report) assert result["valid"] is True assert result["errors"] == [] def test_report_validate_counts_files_checked(self, sample_report: Path) -> None: result = report_validate(sample_report) # At minimum: version.json, report.json, pages/pages.json, # page1/page.json, page1/visuals/visual_def456/visual.json assert result["files_checked"] >= 5 def test_report_validate_missing_report_json(self, tmp_path: Path) -> None: """Validation fails when report.json is absent.""" definition_dir = tmp_path / "Bad.Report" / "definition" definition_dir.mkdir(parents=True) # Write version.json but no report.json _write(definition_dir / "version.json", {"$schema": _SCHEMA_VERSION, "version": "1.0.0"}) result = report_validate(definition_dir) assert result["valid"] is False assert any("report.json" in e for e in result["errors"]) def test_report_validate_missing_version_json(self, tmp_path: Path) -> None: """Validation fails when version.json is absent.""" definition_dir = tmp_path / "NoVer.Report" / "definition" definition_dir.mkdir(parents=True) _write( definition_dir / "report.json", { "$schema": _SCHEMA_REPORT, "themeCollection": {"baseTheme": {}}, "layoutOptimization": "Disabled", }, ) result = report_validate(definition_dir) assert result["valid"] is False assert any("version.json" in e for e in result["errors"]) def test_report_validate_invalid_json(self, sample_report: Path) -> None: """Validation reports an error when a JSON file is malformed.""" (sample_report / "report.json").write_text("{not valid json", encoding="utf-8") result = report_validate(sample_report) assert result["valid"] is False assert any("report.json" in e for e in result["errors"]) def test_report_validate_missing_theme_collection(self, sample_report: Path) -> None: """report.json without 'themeCollection' is invalid.""" _write( sample_report / "report.json", { "$schema": _SCHEMA_REPORT, "layoutOptimization": "Disabled", }, ) result = report_validate(sample_report) assert result["valid"] is False assert any("themeCollection" in e for e in result["errors"]) def test_report_validate_page_missing_page_json(self, sample_report: Path) -> None: """A page folder without page.json is flagged as invalid.""" orphan_page = sample_report / "pages" / "orphan_page" orphan_page.mkdir() (orphan_page / "visuals").mkdir() result = report_validate(sample_report) assert result["valid"] is False assert any("orphan_page" in e for e in result["errors"]) def test_report_validate_nonexistent_folder(self, tmp_path: Path) -> None: """Validation of a path that does not exist reports an error.""" missing = tmp_path / "does_not_exist" / "definition" result = report_validate(missing) assert result["valid"] is False assert result["files_checked"] == 0 # --------------------------------------------------------------------------- # page_list # --------------------------------------------------------------------------- class TestPageList: def test_page_list_returns_list(self, sample_report: Path) -> None: result = page_list(sample_report) assert isinstance(result, list) def test_page_list_correct_count(self, sample_report: Path) -> None: result = page_list(sample_report) assert len(result) == 1 def test_page_list_page_fields(self, sample_report: Path) -> None: page = page_list(sample_report)[0] assert page["name"] == "page1" assert page["display_name"] == "Page One" assert page["ordinal"] == 0 assert page["width"] == 1280 assert page["height"] == 720 assert page["display_option"] == "FitToPage" assert page["visual_count"] == 1 def test_page_list_empty_when_no_pages_dir(self, tmp_path: Path) -> None: """Returns empty list when the pages directory does not exist.""" definition_dir = tmp_path / "NoPages.Report" / "definition" definition_dir.mkdir(parents=True) _write( definition_dir / "report.json", { "$schema": _SCHEMA_REPORT, "themeCollection": {"baseTheme": {}}, "layoutOptimization": "Disabled", }, ) result = page_list(definition_dir) assert result == [] def test_page_list_empty_pages_dir(self, tmp_path: Path) -> None: """Returns empty list when pages directory exists but has no page folders.""" definition_dir = tmp_path / "Empty.Report" / "definition" pages_dir = definition_dir / "pages" pages_dir.mkdir(parents=True) _write(pages_dir / "pages.json", {"$schema": _SCHEMA_PAGES_METADATA, "pageOrder": []}) result = page_list(definition_dir) assert result == [] def test_page_list_respects_page_order(self, sample_report: Path) -> None: """Pages are sorted by the order declared in pages.json.""" pages_dir = sample_report / "pages" _make_page(pages_dir, "page2", "Page Two", ordinal=1, with_visual=False) # Set page2 first in the explicit order _write( pages_dir / "pages.json", { "$schema": _SCHEMA_PAGES_METADATA, "pageOrder": ["page2", "page1"], }, ) result = page_list(sample_report) assert result[0]["name"] == "page2" assert result[1]["name"] == "page1" def test_page_list_falls_back_to_ordinal_sort(self, sample_report: Path) -> None: """Without an explicit pageOrder, pages sort by their ordinal field.""" pages_dir = sample_report / "pages" _make_page(pages_dir, "page2", "Page Two", ordinal=1, with_visual=False) # Remove pageOrder _write(pages_dir / "pages.json", {"$schema": _SCHEMA_PAGES_METADATA, "pageOrder": []}) result = page_list(sample_report) ordinals = [p["ordinal"] for p in result] assert ordinals == sorted(ordinals) def test_page_list_counts_visuals_correctly(self, sample_report: Path) -> None: """Visual count reflects only folders that contain visual.json.""" pages_dir = sample_report / "pages" _make_page(pages_dir, "page2", "Two Visuals", ordinal=1, with_visual=True) # Add a second visual to page2 second_visual = pages_dir / "page2" / "visuals" / "visual_second" second_visual.mkdir() _write( second_visual / "visual.json", { "$schema": _SCHEMA_VISUAL_CONTAINER, "name": "vis2", "position": {"x": 0, "y": 0, "width": 200, "height": 200, "z": 1, "tabOrder": 1}, "visual": {"$schema": _SCHEMA_VISUAL_CONFIG, "visualType": "card", "objects": {}}, }, ) result = page_list(sample_report) page2 = next(p for p in result if p["name"] == "page2") assert page2["visual_count"] == 2 def test_page_list_regular_page_type_is_default(self, sample_report: Path) -> None: """Regular pages (no type field) surface as page_type='Default'.""" pages = page_list(sample_report) assert pages[0]["page_type"] == "Default" def test_page_list_tooltip_page_type(self, sample_report: Path) -> None: """Tooltip pages surface as page_type='Tooltip'.""" page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) _write(page_json, {**data, "type": "Tooltip"}) pages = page_list(sample_report) assert pages[0]["page_type"] == "Tooltip" # --------------------------------------------------------------------------- # page_add # --------------------------------------------------------------------------- class TestPageAdd: def test_page_add_returns_created_status(self, sample_report: Path) -> None: result = page_add(sample_report, "New Page", name="new_page") assert result["status"] == "created" def test_page_add_creates_page_directory(self, sample_report: Path) -> None: page_add(sample_report, "New Page", name="new_page") assert (sample_report / "pages" / "new_page").is_dir() def test_page_add_creates_page_json(self, sample_report: Path) -> None: page_add(sample_report, "New Page", name="new_page") page_json = sample_report / "pages" / "new_page" / "page.json" assert page_json.exists() data = _read(page_json) assert data["name"] == "new_page" assert data["displayName"] == "New Page" def test_page_add_creates_visuals_directory(self, sample_report: Path) -> None: page_add(sample_report, "New Page", name="new_page") assert (sample_report / "pages" / "new_page" / "visuals").is_dir() def test_page_add_respects_custom_dimensions(self, sample_report: Path) -> None: page_add(sample_report, "Wide Page", name="wide_page", width=1920, height=1080) data = _read(sample_report / "pages" / "wide_page" / "page.json") assert data["width"] == 1920 assert data["height"] == 1080 def test_page_add_respects_display_option(self, sample_report: Path) -> None: page_add(sample_report, "Actual Size", name="actual_page", display_option="ActualSize") data = _read(sample_report / "pages" / "actual_page" / "page.json") assert data["displayOption"] == "ActualSize" def test_page_add_auto_name_is_generated(self, sample_report: Path) -> None: """Omitting name generates a non-empty hex identifier.""" result = page_add(sample_report, "Auto Named") assert result["name"] assert len(result["name"]) == 20 # The folder must also exist assert (sample_report / "pages" / result["name"]).is_dir() def test_page_add_auto_name_is_unique(self, sample_report: Path) -> None: """Two sequential auto-named pages receive different names.""" r1 = page_add(sample_report, "Page A") r2 = page_add(sample_report, "Page B") assert r1["name"] != r2["name"] def test_page_add_updates_pages_json(self, sample_report: Path) -> None: """The new page name is appended to pageOrder in pages.json.""" page_add(sample_report, "New Page", name="new_page") meta = _read(sample_report / "pages" / "pages.json") assert "new_page" in meta["pageOrder"] def test_page_add_appends_to_existing_page_order(self, sample_report: Path) -> None: """New page is appended after existing entries, not prepended.""" page_add(sample_report, "New Page", name="new_page") meta = _read(sample_report / "pages" / "pages.json") assert meta["pageOrder"].index("page1") < meta["pageOrder"].index("new_page") def test_page_add_raises_on_duplicate_name(self, sample_report: Path) -> None: """Adding a page whose name already exists raises PbiCliError.""" with pytest.raises(PbiCliError, match="page1"): page_add(sample_report, "Duplicate", name="page1") def test_page_add_is_appended_to_page_order(self, sample_report: Path) -> None: """New page is appended to pages.json pageOrder.""" result = page_add(sample_report, "Second", name="second") assert result["status"] == "created" assert result["name"] == "second" # --------------------------------------------------------------------------- # page_delete # --------------------------------------------------------------------------- class TestPageDelete: def test_page_delete_returns_deleted_status(self, sample_report: Path) -> None: result = page_delete(sample_report, "page1") assert result["status"] == "deleted" assert result["name"] == "page1" def test_page_delete_removes_directory(self, sample_report: Path) -> None: page_delete(sample_report, "page1") assert not (sample_report / "pages" / "page1").exists() def test_page_delete_removes_from_page_order(self, sample_report: Path) -> None: page_delete(sample_report, "page1") meta = _read(sample_report / "pages" / "pages.json") assert "page1" not in meta["pageOrder"] def test_page_delete_removes_visuals_recursively(self, sample_report: Path) -> None: """All nested visual folders are removed along with the page.""" visual_path = sample_report / "pages" / "page1" / "visuals" / "visual_def456" assert visual_path.exists() page_delete(sample_report, "page1") assert not visual_path.exists() def test_page_delete_not_found_raises(self, sample_report: Path) -> None: with pytest.raises(PbiCliError, match="ghost_page"): page_delete(sample_report, "ghost_page") def test_page_delete_only_removes_named_page(self, sample_report: Path) -> None: """Deleting one page leaves other pages intact.""" pages_dir = sample_report / "pages" _make_page(pages_dir, "page2", "Page Two", ordinal=1, with_visual=False) page_delete(sample_report, "page1") assert not (pages_dir / "page1").exists() assert (pages_dir / "page2").exists() # --------------------------------------------------------------------------- # page_get # --------------------------------------------------------------------------- class TestPageGet: def test_page_get_returns_correct_name(self, sample_report: Path) -> None: result = page_get(sample_report, "page1") assert result["name"] == "page1" def test_page_get_returns_display_name(self, sample_report: Path) -> None: result = page_get(sample_report, "page1") assert result["display_name"] == "Page One" def test_page_get_returns_dimensions(self, sample_report: Path) -> None: result = page_get(sample_report, "page1") assert result["width"] == 1280 assert result["height"] == 720 def test_page_get_returns_display_option(self, sample_report: Path) -> None: result = page_get(sample_report, "page1") assert result["display_option"] == "FitToPage" def test_page_get_returns_ordinal(self, sample_report: Path) -> None: result = page_get(sample_report, "page1") assert result["ordinal"] == 0 def test_page_get_counts_visuals(self, sample_report: Path) -> None: result = page_get(sample_report, "page1") assert result["visual_count"] == 1 def test_page_get_not_found_raises(self, sample_report: Path) -> None: with pytest.raises(PbiCliError, match="missing_page"): page_get(sample_report, "missing_page") def test_page_get_zero_visuals_when_folder_empty(self, sample_report: Path) -> None: """A page whose visuals folder has no subdirectories returns 0.""" pages_dir = sample_report / "pages" _make_page(pages_dir, "bare_page", "Bare", ordinal=1, with_visual=False) result = page_get(sample_report, "bare_page") assert result["visual_count"] == 0 def test_page_get_default_page_type(self, sample_report: Path) -> None: """Regular pages surface page_type='Default'; filter_config and visual_interactions None.""" result = page_get(sample_report, "page1") assert result["page_type"] == "Default" assert result["filter_config"] is None assert result["visual_interactions"] is None def test_page_get_tooltip_page_type(self, sample_report: Path) -> None: """Tooltip pages surface page_type='Tooltip'.""" page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) _write(page_json, {**data, "type": "Tooltip"}) result = page_get(sample_report, "page1") assert result["page_type"] == "Tooltip" def test_page_get_surfaces_filter_config(self, sample_report: Path) -> None: """page_get returns filterConfig as-is when present.""" filter_config = {"filters": [{"name": "Filter1", "type": "Categorical"}]} page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) _write(page_json, {**data, "filterConfig": filter_config}) result = page_get(sample_report, "page1") assert result["filter_config"] == filter_config assert result["filter_config"]["filters"][0]["name"] == "Filter1" def test_page_get_surfaces_visual_interactions(self, sample_report: Path) -> None: """page_get returns visualInteractions as-is when present.""" interactions = [{"source": "visual_abc", "target": "visual_def", "type": "NoFilter"}] page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) _write(page_json, {**data, "visualInteractions": interactions}) result = page_get(sample_report, "page1") assert result["visual_interactions"] == interactions assert result["visual_interactions"][0]["type"] == "NoFilter" def test_page_get_page_binding_none_for_regular_page(self, sample_report: Path) -> None: """Regular pages have no pageBinding -- returns None.""" result = page_get(sample_report, "page1") assert result["page_binding"] is None def test_page_get_surfaces_page_binding(self, sample_report: Path) -> None: """Drillthrough pageBinding is returned as-is when present.""" binding = { "name": "Pod", "type": "Drillthrough", "parameters": [ { "name": "Param_Filter1", "boundFilter": "Filter1", } ], } page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) _write(page_json, {**data, "pageBinding": binding}) result = page_get(sample_report, "page1") assert result["page_binding"] == binding assert result["page_binding"]["type"] == "Drillthrough" # --------------------------------------------------------------------------- # theme_set # --------------------------------------------------------------------------- class TestThemeSet: def _make_theme_file(self, tmp_path: Path, name: str = "MyTheme") -> Path: """Create a minimal theme JSON file and return its path.""" theme_file = tmp_path / f"{name}.json" _write( theme_file, { "name": name, "dataColors": ["#118DFF", "#12239E"], "background": "#FFFFFF", }, ) return theme_file def test_theme_set_returns_applied_status(self, sample_report: Path, tmp_path: Path) -> None: theme_file = self._make_theme_file(tmp_path) result = theme_set(sample_report, theme_file) assert result["status"] == "applied" def test_theme_set_returns_theme_name(self, sample_report: Path, tmp_path: Path) -> None: theme_file = self._make_theme_file(tmp_path, name="CorporateBlue") result = theme_set(sample_report, theme_file) assert result["theme"] == "CorporateBlue" def test_theme_set_copies_file_to_registered_resources( self, sample_report: Path, tmp_path: Path ) -> None: theme_file = self._make_theme_file(tmp_path) result = theme_set(sample_report, theme_file) dest = Path(result["file"]) assert dest.exists() def test_theme_set_dest_contains_theme_content( self, sample_report: Path, tmp_path: Path ) -> None: theme_file = self._make_theme_file(tmp_path, name="BrightTheme") result = theme_set(sample_report, theme_file) dest_data = _read(Path(result["file"])) assert dest_data["name"] == "BrightTheme" def test_theme_set_updates_report_json(self, sample_report: Path, tmp_path: Path) -> None: """report.json must have a 'customTheme' entry after theme_set.""" theme_file = self._make_theme_file(tmp_path, name="Teal") theme_set(sample_report, theme_file) report_data = _read(sample_report / "report.json") custom = report_data["themeCollection"].get("customTheme") assert custom is not None assert custom["name"] == "Teal" def test_theme_set_adds_resource_package_entry( self, sample_report: Path, tmp_path: Path ) -> None: """resourcePackages list is created and includes the theme file.""" theme_file = self._make_theme_file(tmp_path, name="Ocean") theme_set(sample_report, theme_file) report_data = _read(sample_report / "report.json") packages: list[dict[str, Any]] = report_data.get("resourcePackages", []) reg = next( (p for p in packages if p.get("name") == "RegisteredResources"), None, ) assert reg is not None items = reg.get("items", []) assert any(i["name"] == "Ocean.json" for i in items) def test_theme_set_idempotent_for_same_theme(self, sample_report: Path, tmp_path: Path) -> None: """Applying the same theme twice does not duplicate resource entries.""" theme_file = self._make_theme_file(tmp_path, name="Stable") theme_set(sample_report, theme_file) theme_set(sample_report, theme_file) report_data = _read(sample_report / "report.json") packages: list[dict[str, Any]] = report_data.get("resourcePackages", []) reg = next(p for p in packages if p.get("name") == "RegisteredResources") items = reg.get("items", []) names = [i["name"] for i in items] # No duplicate entries for the same file assert names.count("Stable.json") == 1 def test_theme_set_missing_theme_file_raises(self, sample_report: Path, tmp_path: Path) -> None: """Referencing a theme file that does not exist raises PbiCliError.""" missing = tmp_path / "ghost_theme.json" with pytest.raises(PbiCliError, match="ghost_theme.json"): theme_set(sample_report, missing) def test_theme_set_dest_path_under_report_folder( self, sample_report: Path, tmp_path: Path ) -> None: """The copied theme file is placed inside the .Report folder hierarchy.""" theme_file = self._make_theme_file(tmp_path, name="DarkMode") result = theme_set(sample_report, theme_file) dest = Path(result["file"]) # definition_path is .Report/definition; dest should be under .Report/ report_folder = sample_report.parent assert dest.is_relative_to(report_folder) # --------------------------------------------------------------------------- # theme_get # --------------------------------------------------------------------------- class TestThemeGet: def test_theme_get_base_only(self, sample_report: Path) -> None: """Reports with no custom theme return base_theme name and Nones.""" result = theme_get(sample_report) assert result["base_theme"] == "CY24SU06" assert result["custom_theme"] is None assert result["theme_data"] is None def test_theme_get_with_custom(self, sample_report: Path, tmp_path: Path) -> None: """After applying a custom theme, theme_get returns its name and data.""" theme_file = tmp_path / "Corporate.json" _write(theme_file, {"name": "Corporate", "dataColors": ["#FF0000"]}) theme_set(sample_report, theme_file) result = theme_get(sample_report) assert result["custom_theme"] == "Corporate" assert result["theme_data"] is not None assert result["theme_data"]["name"] == "Corporate" def test_theme_get_missing_report_json_raises(self, tmp_path: Path) -> None: """theme_get raises PbiCliError when report.json is absent.""" definition_dir = tmp_path / "Bad.Report" / "definition" definition_dir.mkdir(parents=True) with pytest.raises(PbiCliError): theme_get(definition_dir) def test_theme_get_no_base_theme_returns_empty_string(self, tmp_path: Path) -> None: """If themeCollection has no baseTheme, base_theme is an empty string.""" definition_dir = tmp_path / "NoBase.Report" / "definition" definition_dir.mkdir(parents=True) _write( definition_dir / "report.json", { "$schema": _SCHEMA_REPORT, "themeCollection": {}, "layoutOptimization": "Disabled", }, ) result = theme_get(definition_dir) assert result["base_theme"] == "" assert result["custom_theme"] is None # --------------------------------------------------------------------------- # theme_diff # --------------------------------------------------------------------------- class TestThemeDiff: def test_theme_diff_shows_changes(self, sample_report: Path, tmp_path: Path) -> None: """Diff between current and proposed theme reveals added/changed keys.""" # Apply a base custom theme current_file = tmp_path / "Base.json" _write(current_file, {"name": "Base", "background": "#FFFFFF", "foreground": "#000000"}) theme_set(sample_report, current_file) # Proposed: changed background, removed foreground, added accent proposed_file = tmp_path / "Proposed.json" _write(proposed_file, {"name": "Proposed", "background": "#111111", "accent": "#FF0000"}) result = theme_diff(sample_report, proposed_file) assert result["proposed"] == "Proposed" assert "background" in result["changed"] assert "foreground" in result["removed"] assert "accent" in result["added"] def test_theme_diff_identical_returns_empty(self, sample_report: Path, tmp_path: Path) -> None: """Diffing an identical theme file returns empty added/removed/changed.""" theme_file = tmp_path / "Same.json" _write(theme_file, {"name": "Same", "dataColors": ["#118DFF"]}) theme_set(sample_report, theme_file) result = theme_diff(sample_report, theme_file) assert result["added"] == [] assert result["removed"] == [] assert result["changed"] == [] def test_theme_diff_no_custom_all_keys_added(self, sample_report: Path, tmp_path: Path) -> None: """With no custom theme applied, every key in proposed appears in 'added'.""" proposed_file = tmp_path / "New.json" _write(proposed_file, {"name": "New", "background": "#AABBCC", "accent": "#112233"}) result = theme_diff(sample_report, proposed_file) assert result["added"] != [] assert result["removed"] == [] assert result["changed"] == [] def test_theme_diff_current_label_uses_base_when_no_custom( self, sample_report: Path, tmp_path: Path ) -> None: """'current' label falls back to base theme name when no custom theme is set.""" proposed_file = tmp_path / "Any.json" _write(proposed_file, {"name": "Any"}) result = theme_diff(sample_report, proposed_file) assert result["current"] == "CY24SU06" # --------------------------------------------------------------------------- # Task 2 -- page_set_background # --------------------------------------------------------------------------- def test_page_set_background_writes_color(sample_report: Path) -> None: result = page_set_background(sample_report, "page1", "#F8F9FA") assert result["status"] == "updated" assert result["background_color"] == "#F8F9FA" assert result["transparency"] == 0 page_data = _read(sample_report / "pages" / "page1" / "page.json") props = page_data["objects"]["background"][0]["properties"] assert props["color"]["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'" # transparency must always be written so Desktop renders the color as opaque assert props["transparency"]["expr"]["Literal"]["Value"] == "0D" def test_page_set_background_custom_transparency(sample_report: Path) -> None: result = page_set_background(sample_report, "page1", "#0E1117", transparency=50) assert result["transparency"] == 50 page_data = _read(sample_report / "pages" / "page1" / "page.json") props = page_data["objects"]["background"][0]["properties"] assert props["transparency"]["expr"]["Literal"]["Value"] == "50D" def test_page_set_background_rejects_invalid_transparency(sample_report: Path) -> None: with pytest.raises(PbiCliError, match="Invalid transparency"): page_set_background(sample_report, "page1", "#000000", transparency=101) def test_page_set_background_preserves_other_objects(sample_report: Path) -> None: page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) data["objects"] = {"outspace": [{"properties": {"color": {}}}]} page_json.write_text(json.dumps(data, indent=2), encoding="utf-8") page_set_background(sample_report, "page1", "#FFFFFF") updated = _read(page_json) assert "outspace" in updated["objects"] assert "background" in updated["objects"] def test_page_set_background_overrides_existing_background(sample_report: Path) -> None: page_set_background(sample_report, "page1", "#111111") page_set_background(sample_report, "page1", "#AABBCC") data = _read(sample_report / "pages" / "page1" / "page.json") bg = data["objects"]["background"][0]["properties"]["color"] assert bg["solid"]["color"]["expr"]["Literal"]["Value"] == "'#AABBCC'" def test_page_set_background_raises_for_missing_page(sample_report: Path) -> None: with pytest.raises(PbiCliError, match="not found"): page_set_background(sample_report, "no_such_page", "#000000") # --------------------------------------------------------------------------- # Task 3 -- page_set_visibility # --------------------------------------------------------------------------- def test_page_set_visibility_hidden(sample_report: Path) -> None: result = page_set_visibility(sample_report, "page1", hidden=True) assert result["status"] == "updated" assert result["hidden"] is True data = _read(sample_report / "pages" / "page1" / "page.json") assert data.get("visibility") == "HiddenInViewMode" def test_page_set_visibility_visible(sample_report: Path) -> None: # First hide, then show page_json = sample_report / "pages" / "page1" / "page.json" data = _read(page_json) page_json.write_text( json.dumps({**data, "visibility": "HiddenInViewMode"}, indent=2), encoding="utf-8", ) result = page_set_visibility(sample_report, "page1", hidden=False) assert result["hidden"] is False updated = _read(page_json) assert "visibility" not in updated def test_page_set_visibility_idempotent_visible(sample_report: Path) -> None: # Calling visible on an already-visible page should not add visibility key page_set_visibility(sample_report, "page1", hidden=False) data = _read(sample_report / "pages" / "page1" / "page.json") assert "visibility" not in data def test_page_set_visibility_raises_for_missing_page(sample_report: Path) -> None: with pytest.raises(PbiCliError, match="not found"): page_set_visibility(sample_report, "ghost_page", hidden=True) # --------------------------------------------------------------------------- # Fix 4: hex color validation in page_set_background # --------------------------------------------------------------------------- def test_page_set_background_rejects_invalid_color(sample_report: Path) -> None: with pytest.raises(PbiCliError, match="Invalid color"): page_set_background(sample_report, "page1", "F8F9FA") # missing # def test_page_set_background_rejects_invalid_color_wrong_chars(sample_report: Path) -> None: with pytest.raises(PbiCliError, match="Invalid color"): page_set_background(sample_report, "page1", "#GGHHII") # non-hex chars def test_page_set_background_accepts_valid_color(sample_report: Path) -> None: result = page_set_background(sample_report, "page1", "#F8F9FA") assert result["status"] == "updated" assert result["background_color"] == "#F8F9FA" assert result["transparency"] == 0 # --------------------------------------------------------------------------- # Fix 3: is_hidden surfaced in page_list and page_get # --------------------------------------------------------------------------- def test_page_list_shows_hidden_status(sample_report: Path) -> None: pages = page_list(sample_report) assert all("is_hidden" in p for p in pages) # Initially the page is visible assert pages[0]["is_hidden"] is False # Hide the first page and verify is_hidden flips first_page = pages[0]["name"] page_set_visibility(sample_report, first_page, hidden=True) updated = page_list(sample_report) hidden_page = next(p for p in updated if p["name"] == first_page) assert hidden_page["is_hidden"] is True def test_page_get_shows_hidden_status(sample_report: Path) -> None: result = page_get(sample_report, "page1") assert "is_hidden" in result assert result["is_hidden"] is False page_set_visibility(sample_report, "page1", hidden=True) result = page_get(sample_report, "page1") assert result["is_hidden"] is True