fix: resolve CI failures (formatting, mypy, test import)

- Run ruff format on all 26 unformatted files
- Fix mypy strict errors: add explicit typing for json.loads returns,
  add pywin32/websockets to mypy ignore_missing_imports
- Remove yaml dependency from test_skill_triggering.py (use regex parser)
- Fix skill triggering test to handle both single-line and multi-line
  description formats in YAML frontmatter
This commit is contained in:
MinaSaad1 2026-04-02 15:59:49 +02:00
parent 3eb68c56d3
commit 5acb3f33e3
31 changed files with 1258 additions and 1086 deletions

View file

@ -94,3 +94,11 @@ strict = true
[[tool.mypy.overrides]]
module = ["pythonnet", "clr", "clr_loader"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["win32gui", "win32con", "win32api", "win32process", "win32com.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["websockets", "websockets.*"]
ignore_missing_imports = true

View file

@ -14,10 +14,19 @@ if TYPE_CHECKING:
from pbi_cli.main import PbiContext
# Statuses that indicate a write operation (triggers Desktop sync)
_WRITE_STATUSES = frozenset({
"created", "deleted", "updated", "applied", "added",
"cleared", "bound", "removed", "set",
})
_WRITE_STATUSES = frozenset(
{
"created",
"deleted",
"updated",
"applied",
"added",
"cleared",
"bound",
"removed",
"set",
}
)
def run_command(

View file

@ -46,9 +46,7 @@ def info(ctx: PbiContext, click_ctx: click.Context) -> None:
help="Relative path to semantic model folder (e.g. ../MyModel.Dataset).",
)
@pass_context
def create(
ctx: PbiContext, target_path: str, name: str, dataset_path: str | None
) -> None:
def create(ctx: PbiContext, target_path: str, name: str, dataset_path: str | None) -> None:
"""Scaffold a new PBIR report project."""
from pbi_cli.core.report_backend import report_create
@ -168,7 +166,10 @@ def get_theme(ctx: PbiContext, click_ctx: click.Context) -> None:
@report.command(name="diff-theme")
@click.option(
"--file", "-f", required=True, type=click.Path(exists=True),
"--file",
"-f",
required=True,
type=click.Path(exists=True),
help="Proposed theme JSON file.",
)
@click.pass_context
@ -196,9 +197,7 @@ def diff_theme(ctx: PbiContext, click_ctx: click.Context, file: str) -> None:
@click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.")
@click.pass_context
@pass_context
def set_background(
ctx: PbiContext, click_ctx: click.Context, page_name: str, color: str
) -> None:
def set_background(ctx: PbiContext, click_ctx: click.Context, page_name: str, color: str) -> None:
"""Set the background color of a page."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.report_backend import page_set_background
@ -223,9 +222,7 @@ def set_background(
)
@click.pass_context
@pass_context
def set_visibility(
ctx: PbiContext, click_ctx: click.Context, page_name: str, hidden: bool
) -> None:
def set_visibility(ctx: PbiContext, click_ctx: click.Context, page_name: str, hidden: bool) -> None:
"""Hide or show a page in the report navigation."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.report_backend import page_set_visibility

View file

@ -25,7 +25,8 @@ def visual(ctx: click.Context, path: str | None) -> None:
def _get_report_path(click_ctx: click.Context) -> str | None:
"""Extract report_path from parent context."""
if click_ctx.parent:
return click_ctx.parent.obj.get("report_path")
result: str | None = click_ctx.parent.obj.get("report_path")
return result
return None

View file

@ -34,7 +34,8 @@ SCHEMA_BOOKMARK = (
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
return json.loads(path.read_text(encoding="utf-8"))
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@ -93,11 +94,13 @@ def bookmark_list(definition_path: Path) -> list[dict[str, Any]]:
continue
bm = _read_json(bm_file)
exploration = bm.get("explorationState", {})
results.append({
"name": name,
"display_name": bm.get("displayName", ""),
"active_section": exploration.get("activeSection"),
})
results.append(
{
"name": name,
"display_name": bm.get("displayName", ""),
"active_section": exploration.get("activeSection"),
}
)
return results

View file

@ -48,8 +48,7 @@ class VisualTypeError(PbiCliError):
def __init__(self, visual_type: str) -> None:
self.visual_type = visual_type
super().__init__(
f"Unknown visual type '{visual_type}'. "
"Run 'pbi visual types' to see supported types."
f"Unknown visual type '{visual_type}'. Run 'pbi visual types' to see supported types."
)

View file

@ -25,7 +25,8 @@ from pbi_cli.core.pbir_path import get_page_dir, get_visual_dir
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
return json.loads(path.read_text(encoding="utf-8"))
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@ -288,9 +289,7 @@ def filter_add_topn(
"Select": [
{
"Column": {
"Expression": {
"SourceRef": {"Source": cat_alias}
},
"Expression": {"SourceRef": {"Source": cat_alias}},
"Property": column,
},
"Name": "field",
@ -333,9 +332,7 @@ def filter_add_topn(
"Expressions": [
{
"Column": {
"Expression": {
"SourceRef": {"Source": cat_alias}
},
"Expression": {"SourceRef": {"Source": cat_alias}},
"Property": column,
}
}
@ -399,9 +396,7 @@ def filter_add_relative_date(
time_unit_lower = time_unit.strip().lower()
if time_unit_lower not in _RELATIVE_DATE_TIME_UNITS:
valid = ", ".join(_RELATIVE_DATE_TIME_UNITS)
raise PbiCliError(
f"time_unit must be one of {valid}, got '{time_unit}'."
)
raise PbiCliError(f"time_unit must be one of {valid}, got '{time_unit}'.")
time_unit_code = _RELATIVE_DATE_TIME_UNITS[time_unit_lower]
days_code = _RELATIVE_DATE_TIME_UNITS["days"]

View file

@ -21,7 +21,8 @@ from pbi_cli.core.pbir_path import get_visual_dir
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
return json.loads(path.read_text(encoding="utf-8"))
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@ -42,8 +43,7 @@ def _load_visual(definition_path: Path, page_name: str, visual_name: str) -> dic
visual_path = get_visual_dir(definition_path, page_name, visual_name) / "visual.json"
if not visual_path.exists():
raise PbiCliError(
f"Visual '{visual_name}' not found on page '{page_name}'. "
f"Expected: {visual_path}"
f"Visual '{visual_name}' not found on page '{page_name}'. Expected: {visual_path}"
)
return _read_json(visual_path)
@ -176,20 +176,10 @@ def format_background_gradient(
},
"FillRule": {
"linearGradient2": {
"min": {
"color": {
"Literal": {"Value": f"'{min_color}'"}
}
},
"max": {
"color": {
"Literal": {"Value": f"'{max_color}'"}
}
},
"min": {"color": {"Literal": {"Value": f"'{min_color}'"}}},
"max": {"color": {"Literal": {"Value": f"'{max_color}'"}}},
"nullColoringStrategy": {
"strategy": {
"Literal": {"Value": "'asZero'"}
}
"strategy": {"Literal": {"Value": "'asZero'"}}
},
}
},
@ -259,9 +249,7 @@ def format_background_conditional(
comparison_lower = comparison.strip().lower()
if comparison_lower not in _COMPARISON_KINDS:
valid = ", ".join(_COMPARISON_KINDS)
raise PbiCliError(
f"comparison must be one of {valid}, got '{comparison}'."
)
raise PbiCliError(f"comparison must be one of {valid}, got '{comparison}'.")
comparison_kind = _COMPARISON_KINDS[comparison_lower]
if field_query_ref is None:
@ -302,16 +290,10 @@ def format_background_conditional(
"Function": 0,
}
},
"Right": {
"Literal": {
"Value": threshold_literal
}
},
"Right": {"Literal": {"Value": threshold_literal}},
}
},
"Value": {
"Literal": {"Value": f"'{color_hex}'"}
},
"Value": {"Literal": {"Value": f"'{color_hex}'"}},
}
]
}
@ -373,9 +355,7 @@ def format_background_measure(
"color": {
"expr": {
"Measure": {
"Expression": {
"SourceRef": {"Entity": measure_table}
},
"Expression": {"SourceRef": {"Entity": measure_table}},
"Property": measure_property,
}
}

View file

@ -39,46 +39,48 @@ SCHEMA_BOOKMARK = (
# -- Visual type identifiers ------------------------------------------------
SUPPORTED_VISUAL_TYPES: frozenset[str] = frozenset({
# Original 9
"barChart",
"lineChart",
"card",
"pivotTable",
"tableEx",
"slicer",
"kpi",
"gauge",
"donutChart",
# v3.1.0 additions
"columnChart",
"areaChart",
"ribbonChart",
"waterfallChart",
"scatterChart",
"funnelChart",
"multiRowCard",
"treemap",
"cardNew",
"stackedBarChart",
"lineStackedColumnComboChart",
# v3.4.0 additions
"cardVisual",
"actionButton",
# v3.5.0 additions (confirmed from HR Analysis Desktop export)
"clusteredColumnChart",
"clusteredBarChart",
"textSlicer",
"listSlicer",
# v3.6.0 additions (confirmed from HR Analysis Desktop export)
"image",
"shape",
"textbox",
"pageNavigator",
"advancedSlicerVisual",
# v3.8.0 additions
"azureMap",
})
SUPPORTED_VISUAL_TYPES: frozenset[str] = frozenset(
{
# Original 9
"barChart",
"lineChart",
"card",
"pivotTable",
"tableEx",
"slicer",
"kpi",
"gauge",
"donutChart",
# v3.1.0 additions
"columnChart",
"areaChart",
"ribbonChart",
"waterfallChart",
"scatterChart",
"funnelChart",
"multiRowCard",
"treemap",
"cardNew",
"stackedBarChart",
"lineStackedColumnComboChart",
# v3.4.0 additions
"cardVisual",
"actionButton",
# v3.5.0 additions (confirmed from HR Analysis Desktop export)
"clusteredColumnChart",
"clusteredBarChart",
"textSlicer",
"listSlicer",
# v3.6.0 additions (confirmed from HR Analysis Desktop export)
"image",
"shape",
"textbox",
"pageNavigator",
"advancedSlicerVisual",
# v3.8.0 additions
"azureMap",
}
)
# Mapping from user-friendly names to PBIR visualType identifiers
VISUAL_TYPE_ALIASES: dict[str, str] = {

View file

@ -114,9 +114,7 @@ def get_visuals_dir(definition_path: Path, page_name: str) -> Path:
return visuals
def get_visual_dir(
definition_path: Path, page_name: str, visual_name: str
) -> Path:
def get_visual_dir(definition_path: Path, page_name: str, visual_name: str) -> Path:
"""Return the directory for a specific visual."""
return definition_path / "pages" / page_name / "visuals" / visual_name

View file

@ -107,11 +107,13 @@ def validate_bindings_against_model(
ref = _extract_field_ref(sel, sources)
if ref and ref not in valid_fields:
rel = f"{page_dir.name}/visuals/{vdir.name}"
findings.append(ValidationResult(
"warning",
rel,
f"Field '{ref}' not found in semantic model",
))
findings.append(
ValidationResult(
"warning",
rel,
f"Field '{ref}' not found in semantic model",
)
)
except (json.JSONDecodeError, KeyError, TypeError):
continue
@ -151,20 +153,20 @@ def _validate_report_json(definition_path: Path) -> list[ValidationResult]:
findings.append(ValidationResult("warning", "report.json", "Missing $schema reference"))
if "themeCollection" not in data:
findings.append(ValidationResult(
"error", "report.json", "Missing required 'themeCollection'"
))
findings.append(
ValidationResult("error", "report.json", "Missing required 'themeCollection'")
)
else:
tc = data["themeCollection"]
if "baseTheme" not in tc:
findings.append(ValidationResult(
"warning", "report.json", "themeCollection missing 'baseTheme'"
))
findings.append(
ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'")
)
if "layoutOptimization" not in data:
findings.append(ValidationResult(
"error", "report.json", "Missing required 'layoutOptimization'"
))
findings.append(
ValidationResult("error", "report.json", "Missing required 'layoutOptimization'")
)
return findings
@ -201,9 +203,9 @@ def _validate_pages_metadata(definition_path: Path) -> list[ValidationResult]:
page_order = data.get("pageOrder", [])
if not isinstance(page_order, list):
findings.append(ValidationResult(
"error", "pages/pages.json", "'pageOrder' must be an array"
))
findings.append(
ValidationResult("error", "pages/pages.json", "'pageOrder' must be an array")
)
return findings
@ -234,14 +236,15 @@ def _validate_all_pages(definition_path: Path) -> list[ValidationResult]:
findings.append(ValidationResult("error", rel, f"Missing required '{req}'"))
valid_options = {
"FitToPage", "FitToWidth", "ActualSize",
"ActualSizeTopLeft", "DeprecatedDynamic",
"FitToPage",
"FitToWidth",
"ActualSize",
"ActualSizeTopLeft",
"DeprecatedDynamic",
}
opt = data.get("displayOption")
if opt and opt not in valid_options:
findings.append(ValidationResult(
"warning", rel, f"Unknown displayOption '{opt}'"
))
findings.append(ValidationResult("warning", rel, f"Unknown displayOption '{opt}'"))
if opt != "DeprecatedDynamic":
if "width" not in data:
@ -251,9 +254,9 @@ def _validate_all_pages(definition_path: Path) -> list[ValidationResult]:
name = data.get("name", "")
if name and len(name) > 50:
findings.append(ValidationResult(
"warning", rel, f"Name exceeds 50 chars: '{name[:20]}...'"
))
findings.append(
ValidationResult("warning", rel, f"Name exceeds 50 chars: '{name[:20]}...'")
)
return findings
@ -294,18 +297,22 @@ def _validate_all_visuals(definition_path: Path) -> list[ValidationResult]:
pos = data["position"]
for req in ("x", "y", "width", "height"):
if req not in pos:
findings.append(ValidationResult(
"error", rel, f"Position missing required '{req}'"
))
findings.append(
ValidationResult("error", rel, f"Position missing required '{req}'")
)
visual_config = data.get("visual", {})
vtype = visual_config.get("visualType", "")
if not vtype:
# Could be a visualGroup, which is also valid
if "visualGroup" not in data:
findings.append(ValidationResult(
"warning", rel, "Missing 'visual.visualType' (not a visual group either)"
))
findings.append(
ValidationResult(
"warning",
rel,
"Missing 'visual.visualType' (not a visual group either)",
)
)
return findings
@ -331,26 +338,28 @@ def _validate_page_order_consistency(definition_path: Path) -> list[ValidationRe
pages_dir = definition_path / "pages"
actual_pages = {
d.name
for d in pages_dir.iterdir()
if d.is_dir() and (d / "page.json").exists()
d.name for d in pages_dir.iterdir() if d.is_dir() and (d / "page.json").exists()
}
for name in page_order:
if name not in actual_pages:
findings.append(ValidationResult(
"warning",
"pages/pages.json",
f"pageOrder references '{name}' but no such page folder exists",
))
findings.append(
ValidationResult(
"warning",
"pages/pages.json",
f"pageOrder references '{name}' but no such page folder exists",
)
)
unlisted = actual_pages - set(page_order)
for name in sorted(unlisted):
findings.append(ValidationResult(
"info",
"pages/pages.json",
f"Page '{name}' exists but is not listed in pageOrder",
))
findings.append(
ValidationResult(
"info",
"pages/pages.json",
f"Page '{name}' exists but is not listed in pageOrder",
)
)
return findings
@ -381,11 +390,13 @@ def _validate_visual_name_uniqueness(definition_path: Path) -> list[ValidationRe
name = data.get("name", "")
if name in names_seen:
rel = f"pages/{page_dir.name}/visuals/{vdir.name}/visual.json"
findings.append(ValidationResult(
"error",
rel,
f"Duplicate visual name '{name}' (also in {names_seen[name]})",
))
findings.append(
ValidationResult(
"error",
rel,
f"Duplicate visual name '{name}' (also in {names_seen[name]})",
)
)
else:
names_seen[name] = vdir.name
except (json.JSONDecodeError, KeyError):
@ -418,16 +429,12 @@ def _build_result(findings: list[ValidationResult]) -> dict[str, Any]:
}
def _extract_field_ref(
select_item: dict[str, Any], sources: dict[str, str]
) -> str | None:
def _extract_field_ref(select_item: dict[str, Any], sources: dict[str, str]) -> str | None:
"""Extract a Table[Column] reference from a semantic query select item."""
for kind in ("Column", "Measure"):
if kind in select_item:
item = select_item[kind]
source_name = (
item.get("Expression", {}).get("SourceRef", {}).get("Source", "")
)
source_name = item.get("Expression", {}).get("SourceRef", {}).get("Source", "")
prop = item.get("Property", "")
table = sources.get(source_name, source_name)
if table and prop:

View file

@ -34,7 +34,8 @@ from pbi_cli.core.pbir_path import (
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
return json.loads(path.read_text(encoding="utf-8"))
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@ -76,12 +77,14 @@ def report_info(definition_path: Path) -> dict[str, Any]:
for v in visuals_dir.iterdir()
if v.is_dir() and (v / "visual.json").exists()
)
pages.append({
"name": page_data.get("name", page_dir.name),
"display_name": page_data.get("displayName", ""),
"ordinal": page_data.get("ordinal", 0),
"visual_count": visual_count,
})
pages.append(
{
"name": page_data.get("name", page_dir.name),
"display_name": page_data.get("displayName", ""),
"ordinal": page_data.get("ordinal", 0),
"visual_count": visual_count,
}
)
theme = report_data.get("themeCollection", {}).get("baseTheme", {})
@ -115,39 +118,48 @@ def report_create(
pages_dir.mkdir(parents=True, exist_ok=True)
# version.json
_write_json(definition_dir / "version.json", {
"$schema": SCHEMA_VERSION,
"version": "2.0.0",
})
_write_json(
definition_dir / "version.json",
{
"$schema": SCHEMA_VERSION,
"version": "2.0.0",
},
)
# report.json (matches Desktop defaults)
_write_json(definition_dir / "report.json", {
"$schema": SCHEMA_REPORT,
"themeCollection": {
"baseTheme": dict(DEFAULT_BASE_THEME),
_write_json(
definition_dir / "report.json",
{
"$schema": SCHEMA_REPORT,
"themeCollection": {
"baseTheme": dict(DEFAULT_BASE_THEME),
},
"layoutOptimization": "None",
"settings": {
"useStylableVisualContainerHeader": True,
"defaultDrillFilterOtherVisuals": True,
"allowChangeFilterTypes": True,
"useEnhancedTooltips": True,
"useDefaultAggregateDisplayName": True,
},
"slowDataSourceSettings": {
"isCrossHighlightingDisabled": False,
"isSlicerSelectionsButtonEnabled": False,
"isFilterSelectionsButtonEnabled": False,
"isFieldWellButtonEnabled": False,
"isApplyAllButtonEnabled": False,
},
},
"layoutOptimization": "None",
"settings": {
"useStylableVisualContainerHeader": True,
"defaultDrillFilterOtherVisuals": True,
"allowChangeFilterTypes": True,
"useEnhancedTooltips": True,
"useDefaultAggregateDisplayName": True,
},
"slowDataSourceSettings": {
"isCrossHighlightingDisabled": False,
"isSlicerSelectionsButtonEnabled": False,
"isFilterSelectionsButtonEnabled": False,
"isFieldWellButtonEnabled": False,
"isApplyAllButtonEnabled": False,
},
})
)
# pages.json (empty page order)
_write_json(definition_dir / "pages" / "pages.json", {
"$schema": SCHEMA_PAGES_METADATA,
"pageOrder": [],
})
_write_json(
definition_dir / "pages" / "pages.json",
{
"$schema": SCHEMA_PAGES_METADATA,
"pageOrder": [],
},
)
# Scaffold a blank semantic model if no dataset path provided
if not dataset_path:
@ -155,38 +167,47 @@ def report_create(
_scaffold_blank_semantic_model(target_path, name)
# definition.pbir (datasetReference is REQUIRED by Desktop)
_write_json(report_folder / "definition.pbir", {
"version": "4.0",
"datasetReference": {
"byPath": {"path": dataset_path},
_write_json(
report_folder / "definition.pbir",
{
"version": "4.0",
"datasetReference": {
"byPath": {"path": dataset_path},
},
},
})
)
# .platform file for the report
_write_json(report_folder / ".platform", {
"$schema": (
"https://developer.microsoft.com/json-schemas/"
"fabric/gitIntegration/platformProperties/2.0.0/schema.json"
),
"metadata": {
"type": "Report",
"displayName": name,
_write_json(
report_folder / ".platform",
{
"$schema": (
"https://developer.microsoft.com/json-schemas/"
"fabric/gitIntegration/platformProperties/2.0.0/schema.json"
),
"metadata": {
"type": "Report",
"displayName": name,
},
"config": {
"version": "2.0",
"logicalId": "00000000-0000-0000-0000-000000000000",
},
},
"config": {
"version": "2.0",
"logicalId": "00000000-0000-0000-0000-000000000000",
},
})
)
# .pbip project file
_write_json(target_path / f"{name}.pbip", {
"version": "1.0",
"artifacts": [
{
"report": {"path": f"{name}.Report"},
}
],
})
_write_json(
target_path / f"{name}.pbip",
{
"version": "1.0",
"artifacts": [
{
"report": {"path": f"{name}.Report"},
}
],
},
)
return {
"status": "created",
@ -236,9 +257,7 @@ def report_validate(definition_path: Path) -> dict[str, Any]:
pdata = _read_json(page_json)
for req in ("name", "displayName", "displayOption"):
if req not in pdata:
errors.append(
f"Page '{page_dir.name}' missing required '{req}'"
)
errors.append(f"Page '{page_dir.name}' missing required '{req}'")
except json.JSONDecodeError:
pass
@ -281,21 +300,21 @@ def page_list(definition_path: Path) -> list[dict[str, Any]]:
visuals_dir = page_dir / "visuals"
if visuals_dir.is_dir():
visual_count = sum(
1
for v in visuals_dir.iterdir()
if v.is_dir() and (v / "visual.json").exists()
1 for v in visuals_dir.iterdir() if v.is_dir() and (v / "visual.json").exists()
)
results.append({
"name": data.get("name", page_dir.name),
"display_name": data.get("displayName", ""),
"ordinal": data.get("ordinal", 0),
"width": data.get("width", 1280),
"height": data.get("height", 720),
"display_option": data.get("displayOption", "FitToPage"),
"visual_count": visual_count,
"is_hidden": data.get("visibility") == "HiddenInViewMode",
"page_type": data.get("type", "Default"),
})
results.append(
{
"name": data.get("name", page_dir.name),
"display_name": data.get("displayName", ""),
"ordinal": data.get("ordinal", 0),
"width": data.get("width", 1280),
"height": data.get("height", 720),
"display_option": data.get("displayOption", "FitToPage"),
"visual_count": visual_count,
"is_hidden": data.get("visibility") == "HiddenInViewMode",
"page_type": data.get("type", "Default"),
}
)
# Sort by page order if available, then by ordinal
if page_order:
@ -327,14 +346,17 @@ def page_add(
(page_dir / "visuals").mkdir()
# Write page.json (no ordinal - Desktop uses pages.json pageOrder instead)
_write_json(page_dir / "page.json", {
"$schema": SCHEMA_PAGE,
"name": page_name,
"displayName": display_name,
"displayOption": display_option,
"height": height,
"width": width,
})
_write_json(
page_dir / "page.json",
{
"$schema": SCHEMA_PAGE,
"name": page_name,
"displayName": display_name,
"displayOption": display_option,
"height": height,
"width": width,
},
)
# Update pages.json
_update_page_order(definition_path, page_name, action="add")
@ -375,9 +397,7 @@ def page_get(definition_path: Path, page_name: str) -> dict[str, Any]:
visuals_dir = page_dir / "visuals"
if visuals_dir.is_dir():
visual_count = sum(
1
for v in visuals_dir.iterdir()
if v.is_dir() and (v / "visual.json").exists()
1 for v in visuals_dir.iterdir() if v.is_dir() and (v / "visual.json").exists()
)
return {
@ -407,9 +427,7 @@ def page_set_background(
The color must be a hex string, e.g. ``'#F8F9FA'``.
"""
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", color):
raise PbiCliError(
f"Invalid color '{color}' -- expected hex format like '#F8F9FA'."
)
raise PbiCliError(f"Invalid color '{color}' -- expected hex format like '#F8F9FA'.")
page_dir = get_page_dir(definition_path, page_name)
page_json_path = page_dir / "page.json"
@ -419,15 +437,7 @@ def page_set_background(
page_data = _read_json(page_json_path)
background_entry = {
"properties": {
"color": {
"solid": {
"color": {
"expr": {
"Literal": {"Value": f"'{color}'"}
}
}
}
}
"color": {"solid": {"color": {"expr": {"Literal": {"Value": f"'{color}'"}}}}}
}
}
objects = {**page_data.get("objects", {}), "background": [background_entry]}
@ -464,9 +474,7 @@ def page_set_visibility(
# ---------------------------------------------------------------------------
def theme_set(
definition_path: Path, theme_path: Path
) -> dict[str, Any]:
def theme_set(definition_path: Path, theme_path: Path) -> dict[str, Any]:
"""Apply a custom theme JSON to the report."""
if not theme_path.exists():
raise PbiCliError(f"Theme file not found: {theme_path}")
@ -489,9 +497,7 @@ def theme_set(
resources_dir = report_folder / "StaticResources" / "RegisteredResources"
resources_dir.mkdir(parents=True, exist_ok=True)
theme_dest = resources_dir / theme_path.name
theme_dest.write_text(
theme_path.read_text(encoding="utf-8"), encoding="utf-8"
)
theme_dest.write_text(theme_path.read_text(encoding="utf-8"), encoding="utf-8")
# Update resource packages in report.json
resource_packages = report_data.get("resourcePackages", [])
@ -513,15 +519,19 @@ def theme_set(
break
if not found:
resource_packages.append({
"name": "RegisteredResources",
"type": "RegisteredResources",
"items": [{
"name": theme_path.name,
"type": 202,
"path": f"BaseThemes/{theme_path.name}",
}],
})
resource_packages.append(
{
"name": "RegisteredResources",
"type": "RegisteredResources",
"items": [
{
"name": theme_path.name,
"type": 202,
"path": f"BaseThemes/{theme_path.name}",
}
],
}
)
report_data["resourcePackages"] = resource_packages
_write_json(report_json_path, report_data)
@ -670,8 +680,7 @@ def report_convert(
if report_folder is None:
raise PbiCliError(
f"No .Report folder found in '{source_path}'. "
"Expected a folder ending in .Report."
f"No .Report folder found in '{source_path}'. Expected a folder ending in .Report."
)
name = report_folder.name.replace(".Report", "")
@ -680,26 +689,22 @@ def report_convert(
# Create .pbip file
pbip_path = target / f"{name}.pbip"
if pbip_path.exists() and not force:
raise PbiCliError(
f".pbip file already exists at '{pbip_path}'. Use --force to overwrite."
)
_write_json(pbip_path, {
"version": "1.0",
"artifacts": [
{"report": {"path": f"{name}.Report"}},
],
})
raise PbiCliError(f".pbip file already exists at '{pbip_path}'. Use --force to overwrite.")
_write_json(
pbip_path,
{
"version": "1.0",
"artifacts": [
{"report": {"path": f"{name}.Report"}},
],
},
)
# Create .gitignore if not present
gitignore = target / ".gitignore"
gitignore_created = not gitignore.exists()
if gitignore_created:
gitignore_content = (
"# Power BI local settings\n"
".pbi/\n"
"*.pbix\n"
"*.bak\n"
)
gitignore_content = "# Power BI local settings\n.pbi/\n*.pbix\n*.bak\n"
gitignore.write_text(gitignore_content, encoding="utf-8")
# Validate the definition.pbir exists
@ -728,38 +733,40 @@ def _scaffold_blank_semantic_model(target_path: Path, name: str) -> None:
# model.tmdl (minimal valid TMDL)
(defn_dir / "model.tmdl").write_text(
"model Model\n"
" culture: en-US\n"
" defaultPowerBIDataSourceVersion: powerBI_V3\n",
"model Model\n culture: en-US\n defaultPowerBIDataSourceVersion: powerBI_V3\n",
encoding="utf-8",
)
# .platform file (required by Desktop)
_write_json(model_dir / ".platform", {
"$schema": (
"https://developer.microsoft.com/json-schemas/"
"fabric/gitIntegration/platformProperties/2.0.0/schema.json"
),
"metadata": {
"type": "SemanticModel",
"displayName": name,
_write_json(
model_dir / ".platform",
{
"$schema": (
"https://developer.microsoft.com/json-schemas/"
"fabric/gitIntegration/platformProperties/2.0.0/schema.json"
),
"metadata": {
"type": "SemanticModel",
"displayName": name,
},
"config": {
"version": "2.0",
"logicalId": "00000000-0000-0000-0000-000000000000",
},
},
"config": {
"version": "2.0",
"logicalId": "00000000-0000-0000-0000-000000000000",
},
})
)
# definition.pbism (matches Desktop format)
_write_json(model_dir / "definition.pbism", {
"version": "4.1",
"settings": {},
})
_write_json(
model_dir / "definition.pbism",
{
"version": "4.1",
"settings": {},
},
)
def _update_page_order(
definition_path: Path, page_name: str, action: str
) -> None:
def _update_page_order(definition_path: Path, page_name: str, action: str) -> None:
"""Update pages.json with page add/remove."""
pages_meta_path = definition_path / "pages" / "pages.json"

View file

@ -128,9 +128,7 @@ def _list_tmdl_names(tables_dir: Path) -> set[str]:
return {p.stem for p in tables_dir.glob("*.tmdl")}
def _diff_table_entities(
base_text: str, head_text: str
) -> dict[str, list[str]]:
def _diff_table_entities(base_text: str, head_text: str) -> dict[str, list[str]]:
"""Compare entity blocks within two table TMDL files."""
base_entities = _parse_table_entities(base_text)
head_entities = _parse_table_entities(head_text)
@ -226,7 +224,7 @@ def _extract_entity_name(keyword: str, declaration: str) -> str:
# e.g. "measure 'Total Revenue' = ..." -> "Total Revenue"
# e.g. "column ProductID" -> "ProductID"
# e.g. "partition Sales = m" -> "Sales"
rest = declaration[len(keyword):].strip()
rest = declaration[len(keyword) :].strip()
if rest.startswith("'"):
end = rest.find("'", 1)
return rest[1:end] if end > 0 else rest[1:]

View file

@ -26,7 +26,8 @@ from pbi_cli.core.pbir_path import get_visual_dir, get_visuals_dir
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@ -87,16 +88,24 @@ VISUAL_DATA_ROLES: dict[str, list[str]] = {
}
# Roles that should default to Measure references (not Column)
MEASURE_ROLES: frozenset[str] = frozenset({
"Y", "Values", "Fields", # "Fields" is used by cardNew only
"Indicator", "Goal",
# v3.1.0 additions
"ColumnY", "LineY", "X", "Size",
# v3.4.0 additions
"Data",
# v3.8.0 additions
"MaxValue",
})
MEASURE_ROLES: frozenset[str] = frozenset(
{
"Y",
"Values",
"Fields", # "Fields" is used by cardNew only
"Indicator",
"Goal",
# v3.1.0 additions
"ColumnY",
"LineY",
"X",
"Size",
# v3.4.0 additions
"Data",
# v3.8.0 additions
"MaxValue",
}
)
# User-friendly role aliases to PBIR role names
ROLE_ALIASES: dict[str, dict[str, str]] = {
@ -127,7 +136,11 @@ ROLE_ALIASES: dict[str, dict[str, str]] = {
"ribbonChart": {"category": "Category", "value": "Y", "legend": "Legend"},
"waterfallChart": {"category": "Category", "value": "Y", "breakdown": "Breakdown"},
"scatterChart": {
"x": "X", "y": "Y", "detail": "Details", "size": "Size", "legend": "Legend",
"x": "X",
"y": "Y",
"detail": "Details",
"size": "Size",
"legend": "Legend",
"value": "Y",
},
"funnelChart": {"category": "Category", "value": "Y"},
@ -192,8 +205,7 @@ def _build_visual_json(
) -> dict[str, Any]:
"""Fill placeholders in a template string and return parsed JSON."""
filled = (
template_str
.replace("__VISUAL_NAME__", name)
template_str.replace("__VISUAL_NAME__", name)
.replace("__X__", str(x))
.replace("__Y__", str(y))
.replace("__WIDTH__", str(width))
@ -201,7 +213,8 @@ def _build_visual_json(
.replace("__Z__", str(z))
.replace("__TAB_ORDER__", str(tab_order))
)
return json.loads(filled)
result: dict[str, Any] = json.loads(filled)
return result
# ---------------------------------------------------------------------------
@ -255,9 +268,7 @@ DEFAULT_SIZES: dict[str, tuple[float, float]] = {
# ---------------------------------------------------------------------------
def visual_list(
definition_path: Path, page_name: str
) -> list[dict[str, Any]]:
def visual_list(definition_path: Path, page_name: str) -> list[dict[str, Any]]:
"""List all visuals on a page."""
visuals_dir = definition_path / "pages" / page_name / "visuals"
if not visuals_dir.is_dir():
@ -274,33 +285,35 @@ def visual_list(
# Group container: has "visualGroup" key instead of "visual"
if "visualGroup" in data and "visual" not in data:
results.append({
"name": data.get("name", vdir.name),
"visual_type": "group",
"x": 0,
"y": 0,
"width": 0,
"height": 0,
})
results.append(
{
"name": data.get("name", vdir.name),
"visual_type": "group",
"x": 0,
"y": 0,
"width": 0,
"height": 0,
}
)
continue
pos = data.get("position", {})
visual_config = data.get("visual", {})
results.append({
"name": data.get("name", vdir.name),
"visual_type": visual_config.get("visualType", "unknown"),
"x": pos.get("x", 0),
"y": pos.get("y", 0),
"width": pos.get("width", 0),
"height": pos.get("height", 0),
})
results.append(
{
"name": data.get("name", vdir.name),
"visual_type": visual_config.get("visualType", "unknown"),
"x": pos.get("x", 0),
"y": pos.get("y", 0),
"width": pos.get("width", 0),
"height": pos.get("height", 0),
}
)
return results
def visual_get(
definition_path: Path, page_name: str, visual_name: str
) -> dict[str, Any]:
def visual_get(definition_path: Path, page_name: str, visual_name: str) -> dict[str, Any]:
"""Get detailed information about a visual."""
visual_dir = get_visual_dir(definition_path, page_name, visual_name)
vfile = visual_dir / "visual.json"
@ -320,11 +333,13 @@ def visual_get(
for proj in projections:
field = proj.get("field", {})
query_ref = proj.get("queryRef", "")
bindings.append({
"role": role,
"query_ref": query_ref,
"field": _summarize_field(field),
})
bindings.append(
{
"role": role,
"query_ref": query_ref,
"field": _summarize_field(field),
}
)
return {
"name": data.get("name", visual_name),
@ -465,16 +480,12 @@ def visual_set_container(
visual_dir = get_visual_dir(definition_path, page_name, visual_name)
visual_json_path = visual_dir / "visual.json"
if not visual_json_path.exists():
raise PbiCliError(
f"Visual '{visual_name}' not found on page '{page_name}'."
)
raise PbiCliError(f"Visual '{visual_name}' not found on page '{page_name}'.")
data = _read_json(visual_json_path)
visual = data.get("visual")
if visual is None:
raise PbiCliError(
f"Visual '{visual_name}' has invalid JSON -- missing 'visual' key."
)
raise PbiCliError(f"Visual '{visual_name}' has invalid JSON -- missing 'visual' key.")
if border_show is None and background_show is None and title is None:
return {
@ -489,26 +500,17 @@ def visual_set_container(
vco: dict[str, Any] = dict(visual.get("visualContainerObjects", {}))
def _bool_entry(value: bool) -> list[dict[str, Any]]:
return [{
"properties": {
"show": {
"expr": {"Literal": {"Value": str(value).lower()}}
}
}
}]
return [{"properties": {"show": {"expr": {"Literal": {"Value": str(value).lower()}}}}}]
if border_show is not None:
vco = {**vco, "border": _bool_entry(border_show)}
if background_show is not None:
vco = {**vco, "background": _bool_entry(background_show)}
if title is not None:
vco = {**vco, "title": [{
"properties": {
"text": {
"expr": {"Literal": {"Value": f"'{title}'"}}
}
}
}]}
vco = {
**vco,
"title": [{"properties": {"text": {"expr": {"Literal": {"Value": f"'{title}'"}}}}}],
}
updated_visual = {**visual, "visualContainerObjects": vco}
_write_json(visual_json_path, {**data, "visual": updated_visual})
@ -523,9 +525,7 @@ def visual_set_container(
}
def visual_delete(
definition_path: Path, page_name: str, visual_name: str
) -> dict[str, Any]:
def visual_delete(definition_path: Path, page_name: str, visual_name: str) -> dict[str, Any]:
"""Delete a visual from a page."""
visual_dir = get_visual_dir(definition_path, page_name, visual_name)
@ -638,28 +638,34 @@ def visual_bind(
"Property": column,
}
}
select_items.append({
**cmd_field_expr,
"Name": query_ref,
})
select_items.append(
{
**cmd_field_expr,
"Name": query_ref,
}
)
applied.append({
"role": pbir_role,
"field": field_ref,
"query_ref": query_ref,
})
applied.append(
{
"role": pbir_role,
"field": field_ref,
"query_ref": query_ref,
}
)
# Set the semantic query Commands block (merges with existing)
if from_entities and select_items:
query["Commands"] = [{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": list(from_entities.values()),
"Select": select_items,
query["Commands"] = [
{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": list(from_entities.values()),
"Select": select_items,
}
}
}
}]
]
data["visual"] = visual_config
_write_json(vfile, data)
@ -690,9 +696,7 @@ def _parse_field_ref(ref: str) -> tuple[str, str]:
column = match.group(2).strip()
return table, column
raise PbiCliError(
f"Invalid field reference '{ref}'. Expected 'Table[Column]' format."
)
raise PbiCliError(f"Invalid field reference '{ref}'. Expected 'Table[Column]' format.")
def _summarize_field(field: dict[str, Any]) -> str:
@ -878,12 +882,14 @@ def visual_calc_list(
for proj in state.get("projections", []):
nvc = proj.get("field", {}).get("NativeVisualCalculation")
if nvc is not None:
results.append({
"name": nvc.get("Name", ""),
"expression": nvc.get("Expression", ""),
"role": role,
"query_ref": proj.get("queryRef", "select"),
})
results.append(
{
"name": nvc.get("Name", ""),
"expression": nvc.get("Expression", ""),
"role": role,
"query_ref": proj.get("queryRef", "select"),
}
)
return results
@ -905,15 +911,14 @@ def visual_calc_delete(
raise PbiCliError(f"Visual '{visual_name}' not found on page '{page_name}'.")
data = _read_json(vfile)
query_state = (
data.get("visual", {}).get("query", {}).get("queryState", {})
)
query_state = data.get("visual", {}).get("query", {}).get("queryState", {})
found = False
for role, state in query_state.items():
projections: list[dict[str, Any]] = state.get("projections", [])
new_projections = [
proj for proj in projections
proj
for proj in projections
if proj.get("field", {}).get("NativeVisualCalculation", {}).get("Name") != calc_name
]
if len(new_projections) < len(projections):
@ -921,9 +926,7 @@ def visual_calc_delete(
found = True
if not found:
raise PbiCliError(
f"Visual calculation '{calc_name}' not found in visual '{visual_name}'."
)
raise PbiCliError(f"Visual calculation '{calc_name}' not found in visual '{visual_name}'.")
_write_json(vfile, data)
return {"status": "deleted", "visual": visual_name, "name": calc_name}

View file

@ -29,12 +29,12 @@ def render_report(definition_path: Path) -> str:
for page_dir in page_dirs:
pages_html.append(_render_page(page_dir))
pages_content = "\n".join(pages_html) if pages_html else "<p class='empty'>No pages in report</p>"
return _HTML_TEMPLATE.replace("{{THEME}}", escape(theme)).replace(
"{{PAGES}}", pages_content
pages_content = (
"\n".join(pages_html) if pages_html else "<p class='empty'>No pages in report</p>"
)
return _HTML_TEMPLATE.replace("{{THEME}}", escape(theme)).replace("{{PAGES}}", pages_content)
def render_page(definition_path: Path, page_name: str) -> str:
"""Render a single page as HTML."""
@ -218,7 +218,7 @@ def _render_visual_content(vtype: str, w: float, h: float, bindings: list[str])
for i in range(num_bars):
bar_h = body_h * (0.3 + 0.5 * ((i * 37 + 13) % 7) / 7)
bars += (
f'<rect x="{i * bar_w * 2 + bar_w/2}" y="{body_h - bar_h}" '
f'<rect x="{i * bar_w * 2 + bar_w / 2}" y="{body_h - bar_h}" '
f'width="{bar_w}" height="{bar_h}" fill="#4472C4" opacity="0.7"/>'
)
return f'<svg class="chart-svg" viewBox="0 0 {w} {body_h}">{bars}</svg>'
@ -230,7 +230,9 @@ def _render_visual_content(vtype: str, w: float, h: float, bindings: list[str])
px = (w / (num_points - 1)) * i
py = body_h * (0.2 + 0.6 * ((i * 47 + 23) % 11) / 11)
points.append(f"{px},{py}")
polyline = f'<polyline points="{" ".join(points)}" fill="none" stroke="#ED7D31" stroke-width="3"/>'
polyline = (
f'<polyline points="{" ".join(points)}" fill="none" stroke="#ED7D31" stroke-width="3"/>'
)
return f'<svg class="chart-svg" viewBox="0 0 {w} {body_h}">{polyline}</svg>'
if vtype == "card":
@ -286,13 +288,15 @@ def _get_page_order(definition_path: Path) -> list[str]:
return []
try:
data = json.loads(pages_json.read_text(encoding="utf-8"))
return data.get("pageOrder", [])
order: list[str] = data.get("pageOrder", [])
return order
except (json.JSONDecodeError, KeyError):
return []
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result
# ---------------------------------------------------------------------------

View file

@ -27,7 +27,7 @@ def start_preview_server(
"""
# Check for websockets dependency
try:
import websockets # type: ignore[import-untyped]
import websockets
except ImportError:
return {
"status": "error",

View file

@ -48,9 +48,9 @@ def reload_desktop() -> dict[str, Any]:
def _try_pywin32() -> dict[str, Any] | None:
"""Try to use pywin32 to send a reload shortcut to PBI Desktop."""
try:
import win32api # type: ignore[import-untyped]
import win32con # type: ignore[import-untyped]
import win32gui # type: ignore[import-untyped]
import win32api
import win32con
import win32gui
except ImportError:
return None
@ -93,7 +93,7 @@ def _try_pywin32() -> dict[str, Any] | None:
def _find_pbi_window_pywin32() -> int:
"""Find Power BI Desktop's main window handle via pywin32."""
import win32gui # type: ignore[import-untyped]
import win32gui
result = 0

View file

@ -37,8 +37,8 @@ def sync_desktop(
files here are snapshotted before Desktop saves and restored after.
"""
try:
import win32con # type: ignore[import-untyped] # noqa: F401
import win32gui # type: ignore[import-untyped] # noqa: F401
import win32con # noqa: F401
import win32gui # noqa: F401
except ImportError:
return {
"status": "manual",
@ -84,6 +84,7 @@ def sync_desktop(
# Snapshot / Restore
# ---------------------------------------------------------------------------
def _snapshot_recent_changes(
definition_path: str | Path | None,
max_age_seconds: float = 5.0,
@ -126,12 +127,13 @@ def _restore_snapshots(snapshots: dict[Path, bytes]) -> list[str]:
# Desktop process discovery
# ---------------------------------------------------------------------------
def _find_desktop_process(
pbip_hint: str | Path | None,
) -> dict[str, Any] | None:
"""Find the PBI Desktop window, its PID, and the .pbip file it has open."""
import win32gui # type: ignore[import-untyped]
import win32process # type: ignore[import-untyped]
import win32gui
import win32process
hint_stem = None
if pbip_hint is not None:
@ -163,13 +165,15 @@ def _find_desktop_process(
if hint_stem not in Path(pbip_path).stem.lower():
return True
matches.append({
"hwnd": hwnd,
"pid": pid,
"title": title,
"exe_path": exe_path,
"pbip_path": pbip_path,
})
matches.append(
{
"hwnd": hwnd,
"pid": pid,
"title": title,
"exe_path": exe_path,
"pbip_path": pbip_path,
}
)
return True
try:
@ -185,8 +189,13 @@ def _get_process_info(pid: int) -> dict[str, str] | None:
try:
out = subprocess.check_output(
[
"wmic", "process", "where", f"ProcessId={pid}",
"get", "ExecutablePath,CommandLine", "/format:list",
"wmic",
"process",
"where",
f"ProcessId={pid}",
"get",
"ExecutablePath,CommandLine",
"/format:list",
],
text=True,
stderr=subprocess.DEVNULL,
@ -215,13 +224,14 @@ def _get_process_info(pid: int) -> dict[str, str] | None:
# Close with save
# ---------------------------------------------------------------------------
def _close_with_save(hwnd: int, pid: int) -> dict[str, Any] | None:
"""Close Desktop via WM_CLOSE and click Save in the dialog.
Returns an error dict on failure, or None on success.
"""
import win32con # type: ignore[import-untyped]
import win32gui # type: ignore[import-untyped]
import win32con
import win32gui
win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0)
time.sleep(2)
@ -252,7 +262,7 @@ def _accept_save_dialog() -> None:
[Save] [Don't Save] [Cancel]
'Save' is the default focused button, so Enter clicks it.
"""
import win32gui # type: ignore[import-untyped]
import win32gui
dialog_found = False
@ -287,10 +297,11 @@ def _accept_save_dialog() -> None:
# Reopen / utilities
# ---------------------------------------------------------------------------
def _reopen_pbip(pbip_path: str) -> dict[str, Any]:
"""Launch the .pbip file with the system default handler."""
try:
os.startfile(pbip_path) # type: ignore[attr-defined]
os.startfile(pbip_path) # noqa: S606 # Windows-only API
return {
"status": "success",
"method": "pywin32",
@ -321,6 +332,6 @@ def _process_alive(pid: int) -> bool:
def _get_wscript_shell() -> Any:
"""Get a WScript.Shell COM object for SendKeys."""
import win32com.client # type: ignore[import-untyped]
import win32com.client
return win32com.client.Dispatch("WScript.Shell")

View file

@ -216,19 +216,16 @@ def test_bookmark_set_visibility_hide_sets_display_mode(definition_path: Path) -
"""set_visibility with hidden=True writes display.mode='hidden' on singleVisual."""
bookmark_add(definition_path, "Hide Test", "page_a", name="bm_hide")
result = bookmark_set_visibility(
definition_path, "bm_hide", "page_a", "visual_x", hidden=True
)
result = bookmark_set_visibility(definition_path, "bm_hide", "page_a", "visual_x", hidden=True)
assert result["status"] == "updated"
assert result["hidden"] is True
bm_file = definition_path / "bookmarks" / "bm_hide.bookmark.json"
data = json.loads(bm_file.read_text(encoding="utf-8"))
single = (
data["explorationState"]["sections"]["page_a"]
["visualContainers"]["visual_x"]["singleVisual"]
)
single = data["explorationState"]["sections"]["page_a"]["visualContainers"]["visual_x"][
"singleVisual"
]
assert single["display"] == {"mode": "hidden"}
@ -237,19 +234,14 @@ def test_bookmark_set_visibility_show_removes_display_key(definition_path: Path)
bookmark_add(definition_path, "Show Test", "page_b", name="bm_show")
# First hide it, then show it
bookmark_set_visibility(
definition_path, "bm_show", "page_b", "visual_y", hidden=True
)
bookmark_set_visibility(
definition_path, "bm_show", "page_b", "visual_y", hidden=False
)
bookmark_set_visibility(definition_path, "bm_show", "page_b", "visual_y", hidden=True)
bookmark_set_visibility(definition_path, "bm_show", "page_b", "visual_y", hidden=False)
bm_file = definition_path / "bookmarks" / "bm_show.bookmark.json"
data = json.loads(bm_file.read_text(encoding="utf-8"))
single = (
data["explorationState"]["sections"]["page_b"]
["visualContainers"]["visual_y"]["singleVisual"]
)
single = data["explorationState"]["sections"]["page_b"]["visualContainers"]["visual_y"][
"singleVisual"
]
assert "display" not in single
@ -258,16 +250,12 @@ def test_bookmark_set_visibility_creates_path_if_absent(definition_path: Path) -
bookmark_add(definition_path, "New Path", "page_c", name="bm_newpath")
# The bookmark was created without any sections; the function should create them.
bookmark_set_visibility(
definition_path, "bm_newpath", "page_c", "visual_z", hidden=True
)
bookmark_set_visibility(definition_path, "bm_newpath", "page_c", "visual_z", hidden=True)
bm_file = definition_path / "bookmarks" / "bm_newpath.bookmark.json"
data = json.loads(bm_file.read_text(encoding="utf-8"))
assert "page_c" in data["explorationState"]["sections"]
assert "visual_z" in (
data["explorationState"]["sections"]["page_c"]["visualContainers"]
)
assert "visual_z" in (data["explorationState"]["sections"]["page_c"]["visualContainers"])
def test_bookmark_set_visibility_preserves_existing_single_visual_keys(
@ -277,28 +265,23 @@ def test_bookmark_set_visibility_preserves_existing_single_visual_keys(
bookmark_add(definition_path, "Preserve", "page_d", name="bm_preserve")
# Pre-populate a singleVisual with a visualType key via set_visibility helper
bookmark_set_visibility(
definition_path, "bm_preserve", "page_d", "visual_w", hidden=False
)
bookmark_set_visibility(definition_path, "bm_preserve", "page_d", "visual_w", hidden=False)
# Manually inject a visualType into the singleVisual
bm_file = definition_path / "bookmarks" / "bm_preserve.bookmark.json"
raw = json.loads(bm_file.read_text(encoding="utf-8"))
raw["explorationState"]["sections"]["page_d"]["visualContainers"]["visual_w"][
"singleVisual"
]["visualType"] = "barChart"
raw["explorationState"]["sections"]["page_d"]["visualContainers"]["visual_w"]["singleVisual"][
"visualType"
] = "barChart"
bm_file.write_text(json.dumps(raw, indent=2), encoding="utf-8")
# Now hide and verify visualType is retained
bookmark_set_visibility(
definition_path, "bm_preserve", "page_d", "visual_w", hidden=True
)
bookmark_set_visibility(definition_path, "bm_preserve", "page_d", "visual_w", hidden=True)
updated = json.loads(bm_file.read_text(encoding="utf-8"))
single = (
updated["explorationState"]["sections"]["page_d"]
["visualContainers"]["visual_w"]["singleVisual"]
)
single = updated["explorationState"]["sections"]["page_d"]["visualContainers"]["visual_w"][
"singleVisual"
]
assert single["visualType"] == "barChart"
assert single["display"] == {"mode": "hidden"}
@ -308,6 +291,4 @@ def test_bookmark_set_visibility_raises_for_unknown_bookmark(
) -> None:
"""set_visibility raises PbiCliError when the bookmark does not exist."""
with pytest.raises(PbiCliError, match="not found"):
bookmark_set_visibility(
definition_path, "nonexistent", "page_x", "visual_x", hidden=True
)
bookmark_set_visibility(definition_path, "nonexistent", "page_x", "visual_x", hidden=True)

View file

@ -156,9 +156,7 @@ def test_where_by_y_min(multi_visual_page: Path) -> None:
def test_where_type_and_position_combined(multi_visual_page: Path) -> None:
"""Combining type and x_max narrows results correctly."""
result = visual_where(
multi_visual_page, "test_page", visual_type="barChart", x_max=400
)
result = visual_where(multi_visual_page, "test_page", visual_type="barChart", x_max=400)
assert len(result) == 1
assert result[0]["name"] == "BarChart_1"

View file

@ -58,15 +58,18 @@ def _make_page(definition_path: Path, page_name: str, display_name: str = "Overv
"""Create a minimal page folder and return the page dir."""
page_dir = definition_path / "pages" / 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": 0,
})
_write(
page_dir / "page.json",
{
"$schema": _SCHEMA_PAGE,
"name": page_name,
"displayName": display_name,
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
"ordinal": 0,
},
)
return page_dir
@ -74,17 +77,20 @@ def _make_visual(page_dir: Path, visual_name: str) -> Path:
"""Create a minimal visual folder and return the visual dir."""
visual_dir = page_dir / "visuals" / visual_name
visual_dir.mkdir(parents=True, exist_ok=True)
_write(visual_dir / "visual.json", {
"$schema": _SCHEMA_VISUAL_CONTAINER,
"name": visual_name,
"position": {"x": 0, "y": 0, "width": 400, "height": 300, "z": 0, "tabOrder": 0},
"visual": {
"$schema": _SCHEMA_VISUAL_CONFIG,
"visualType": "barChart",
"query": {"queryState": {}},
"objects": {},
_write(
visual_dir / "visual.json",
{
"$schema": _SCHEMA_VISUAL_CONTAINER,
"name": visual_name,
"position": {"x": 0, "y": 0, "width": 400, "height": 300, "z": 0, "tabOrder": 0},
"visual": {
"$schema": _SCHEMA_VISUAL_CONFIG,
"visualType": "barChart",
"query": {"queryState": {}},
"objects": {},
},
},
})
)
return visual_dir
@ -123,9 +129,7 @@ def test_filter_list_empty_visual(definition_path: Path) -> None:
def test_filter_list_with_filters(definition_path: Path) -> None:
"""filter_list returns the filters after one is added."""
filter_add_categorical(
definition_path, "page_overview", "Sales", "Region", ["North", "South"]
)
filter_add_categorical(definition_path, "page_overview", "Sales", "Region", ["North", "South"])
result = filter_list(definition_path, "page_overview")
assert len(result) == 1
assert result[0]["type"] == "Categorical"
@ -193,9 +197,7 @@ def test_filter_add_categorical_json_structure(definition_path: Path) -> None:
def test_filter_add_categorical_alias_from_table_name(definition_path: Path) -> None:
"""Source alias uses the first character of the table name, lowercased."""
filter_add_categorical(
definition_path, "page_overview", "Sales", "Product", ["Widget"]
)
filter_add_categorical(definition_path, "page_overview", "Sales", "Product", ["Widget"])
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]
alias = f["filter"]["From"][0]["Name"]
@ -243,7 +245,11 @@ def test_filter_add_categorical_visual_scope(definition_path: Path) -> None:
def test_filter_list_visual_after_add(definition_path: Path) -> None:
"""filter_list on a visual returns the added filter."""
filter_add_categorical(
definition_path, "page_overview", "Sales", "Year", ["2024"],
definition_path,
"page_overview",
"Sales",
"Year",
["2024"],
visual_name="visual_abc123",
)
result = filter_list(definition_path, "page_overview", visual_name="visual_abc123")
@ -281,8 +287,13 @@ def test_filter_remove_raises_for_unknown_name(definition_path: Path) -> None:
def test_filter_remove_visual(definition_path: Path) -> None:
"""filter_remove works on visual-level filters."""
filter_add_categorical(
definition_path, "page_overview", "Sales", "Year", ["2024"],
visual_name="visual_abc123", name="vis_filter_x",
definition_path,
"page_overview",
"Sales",
"Year",
["2024"],
visual_name="visual_abc123",
name="vis_filter_x",
)
result = filter_remove(
definition_path, "page_overview", "vis_filter_x", visual_name="visual_abc123"
@ -298,9 +309,7 @@ def test_filter_remove_visual(definition_path: Path) -> None:
def test_filter_clear_removes_all(definition_path: Path) -> None:
"""filter_clear removes every filter and returns the correct count."""
filter_add_categorical(
definition_path, "page_overview", "Sales", "Region", ["East"], name="f1"
)
filter_add_categorical(definition_path, "page_overview", "Sales", "Region", ["East"], name="f1")
filter_add_categorical(
definition_path, "page_overview", "Sales", "Product", ["Widget"], name="f2"
)
@ -319,7 +328,11 @@ def test_filter_clear_empty_page(definition_path: Path) -> None:
def test_filter_clear_visual_scope(definition_path: Path) -> None:
"""filter_clear on a visual uses scope='visual'."""
filter_add_categorical(
definition_path, "page_overview", "Sales", "Year", ["2024"],
definition_path,
"page_overview",
"Sales",
"Year",
["2024"],
visual_name="visual_abc123",
)
result = filter_clear(definition_path, "page_overview", visual_name="visual_abc123")
@ -346,7 +359,11 @@ def test_multiple_adds_accumulate(definition_path: Path) -> None:
"""Each call to filter_add_categorical appends rather than replaces."""
for i in range(3):
filter_add_categorical(
definition_path, "page_overview", "Sales", "Region", [f"Region{i}"],
definition_path,
"page_overview",
"Sales",
"Region",
[f"Region{i}"],
name=f"filter_{i}",
)
result = filter_list(definition_path, "page_overview")
@ -361,9 +378,13 @@ def test_multiple_adds_accumulate(definition_path: Path) -> None:
def test_filter_add_topn_returns_status(definition_path: Path) -> None:
"""filter_add_topn returns the expected status dict."""
result = filter_add_topn(
definition_path, "page_overview",
table="financials", column="Country",
n=3, order_by_table="financials", order_by_column="Sales",
definition_path,
"page_overview",
table="financials",
column="Country",
n=3,
order_by_table="financials",
order_by_column="Sales",
)
assert result["status"] == "added"
assert result["type"] == "TopN"
@ -376,9 +397,13 @@ def test_filter_add_topn_returns_status(definition_path: Path) -> None:
def test_filter_add_topn_persisted(definition_path: Path) -> None:
"""filter_add_topn writes a TopN filter entry to page.json."""
filter_add_topn(
definition_path, "page_overview",
table="financials", column="Country",
n=3, order_by_table="financials", order_by_column="Sales",
definition_path,
"page_overview",
table="financials",
column="Country",
n=3,
order_by_table="financials",
order_by_column="Sales",
name="topn_test",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
@ -394,9 +419,13 @@ def test_filter_add_topn_persisted(definition_path: Path) -> None:
def test_filter_add_topn_subquery_structure(definition_path: Path) -> None:
"""The TopN filter has the correct Subquery/From/Where structure."""
filter_add_topn(
definition_path, "page_overview",
table="financials", column="Country",
n=5, order_by_table="financials", order_by_column="Sales",
definition_path,
"page_overview",
table="financials",
column="Country",
n=5,
order_by_table="financials",
order_by_column="Sales",
name="topn_struct",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
@ -419,10 +448,15 @@ def test_filter_add_topn_subquery_structure(definition_path: Path) -> None:
def test_filter_add_topn_direction_bottom(definition_path: Path) -> None:
"""direction='Bottom' produces PBI Direction=1 in the OrderBy."""
filter_add_topn(
definition_path, "page_overview",
table="financials", column="Country",
n=3, order_by_table="financials", order_by_column="Profit",
direction="Bottom", name="topn_bottom",
definition_path,
"page_overview",
table="financials",
column="Country",
n=3,
order_by_table="financials",
order_by_column="Profit",
direction="Bottom",
name="topn_bottom",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]["filter"]
@ -434,9 +468,13 @@ def test_filter_add_topn_invalid_direction(definition_path: Path) -> None:
"""filter_add_topn raises PbiCliError for an unknown direction."""
with pytest.raises(PbiCliError):
filter_add_topn(
definition_path, "page_overview",
table="financials", column="Country",
n=3, order_by_table="financials", order_by_column="Sales",
definition_path,
"page_overview",
table="financials",
column="Country",
n=3,
order_by_table="financials",
order_by_column="Sales",
direction="Middle",
)
@ -444,9 +482,13 @@ def test_filter_add_topn_invalid_direction(definition_path: Path) -> None:
def test_filter_add_topn_visual_scope(definition_path: Path) -> None:
"""filter_add_topn adds a visual filter with scope='visual' and no howCreated."""
result = filter_add_topn(
definition_path, "page_overview",
table="financials", column="Country",
n=3, order_by_table="financials", order_by_column="Sales",
definition_path,
"page_overview",
table="financials",
column="Country",
n=3,
order_by_table="financials",
order_by_column="Sales",
visual_name="visual_abc123",
)
assert result["scope"] == "visual"
@ -465,9 +507,12 @@ def test_filter_add_topn_visual_scope(definition_path: Path) -> None:
def test_filter_add_relative_date_returns_status(definition_path: Path) -> None:
"""filter_add_relative_date returns the expected status dict."""
result = filter_add_relative_date(
definition_path, "page_overview",
table="financials", column="Date",
amount=3, time_unit="months",
definition_path,
"page_overview",
table="financials",
column="Date",
amount=3,
time_unit="months",
)
assert result["status"] == "added"
assert result["type"] == "RelativeDate"
@ -479,9 +524,12 @@ def test_filter_add_relative_date_returns_status(definition_path: Path) -> None:
def test_filter_add_relative_date_persisted(definition_path: Path) -> None:
"""filter_add_relative_date writes a RelativeDate entry to page.json."""
filter_add_relative_date(
definition_path, "page_overview",
table="financials", column="Date",
amount=3, time_unit="months",
definition_path,
"page_overview",
table="financials",
column="Date",
amount=3,
time_unit="months",
name="reldate_test",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
@ -497,9 +545,12 @@ def test_filter_add_relative_date_persisted(definition_path: Path) -> None:
def test_filter_add_relative_date_between_structure(definition_path: Path) -> None:
"""The RelativeDate filter uses a Between/DateAdd/DateSpan/Now structure."""
filter_add_relative_date(
definition_path, "page_overview",
table="financials", column="Date",
amount=3, time_unit="months",
definition_path,
"page_overview",
table="financials",
column="Date",
amount=3,
time_unit="months",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]["filter"]
@ -526,9 +577,12 @@ def test_filter_add_relative_date_between_structure(definition_path: Path) -> No
def test_filter_add_relative_date_time_unit_years(definition_path: Path) -> None:
"""time_unit='years' maps to TimeUnit=3 in the DateAdd."""
filter_add_relative_date(
definition_path, "page_overview",
table="financials", column="Date",
amount=1, time_unit="years",
definition_path,
"page_overview",
table="financials",
column="Date",
amount=1,
time_unit="years",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]["filter"]
@ -541,18 +595,24 @@ def test_filter_add_relative_date_invalid_unit(definition_path: Path) -> None:
"""filter_add_relative_date raises PbiCliError for an unknown time_unit."""
with pytest.raises(PbiCliError):
filter_add_relative_date(
definition_path, "page_overview",
table="financials", column="Date",
amount=3, time_unit="quarters",
definition_path,
"page_overview",
table="financials",
column="Date",
amount=3,
time_unit="quarters",
)
def test_filter_add_relative_date_visual_scope(definition_path: Path) -> None:
"""filter_add_relative_date adds a visual filter with no howCreated key."""
result = filter_add_relative_date(
definition_path, "page_overview",
table="financials", column="Date",
amount=7, time_unit="days",
definition_path,
"page_overview",
table="financials",
column="Date",
amount=7,
time_unit="days",
visual_name="visual_abc123",
)
assert result["scope"] == "visual"

View file

@ -158,9 +158,7 @@ def report_with_visual(tmp_path: Path) -> Path:
def _read_visual(definition: Path) -> dict[str, Any]:
path = (
definition / "pages" / PAGE_NAME / "visuals" / VISUAL_NAME / "visual.json"
)
path = definition / "pages" / PAGE_NAME / "visuals" / VISUAL_NAME / "visual.json"
return json.loads(path.read_text(encoding="utf-8"))
@ -216,9 +214,7 @@ def test_format_background_gradient_correct_structure(report_with_visual: Path)
data = _read_visual(report_with_visual)
entry = data["visual"]["objects"]["values"][0]
fill_rule_expr = (
entry["properties"]["backColor"]["solid"]["color"]["expr"]["FillRule"]
)
fill_rule_expr = entry["properties"]["backColor"]["solid"]["color"]["expr"]["FillRule"]
assert "linearGradient2" in fill_rule_expr["FillRule"]
linear = fill_rule_expr["FillRule"]["linearGradient2"]
assert "min" in linear
@ -511,7 +507,9 @@ FIELD_UNITS = "Sum(financials.Units Sold)"
def test_format_background_conditional_adds_entry(report_with_visual: Path) -> None:
"""format_background_conditional creates an entry in objects.values."""
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@ -527,7 +525,9 @@ def test_format_background_conditional_adds_entry(report_with_visual: Path) -> N
def test_format_background_conditional_correct_structure(report_with_visual: Path) -> None:
"""Conditional entry has Conditional.Cases with ComparisonKind and color."""
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@ -549,7 +549,9 @@ def test_format_background_conditional_correct_structure(report_with_visual: Pat
def test_format_background_conditional_selector_metadata(report_with_visual: Path) -> None:
"""Conditional entry selector.metadata equals the supplied field_query_ref."""
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@ -567,7 +569,9 @@ def test_format_background_conditional_default_field_query_ref(
) -> None:
"""When field_query_ref is omitted, it defaults to 'Sum(table.column)'."""
result = format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@ -580,15 +584,23 @@ def test_format_background_conditional_default_field_query_ref(
def test_format_background_conditional_replaces_existing(report_with_visual: Path) -> None:
"""Applying conditional twice on same field_query_ref replaces the entry."""
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
input_table="financials", input_column="Units Sold",
threshold=100000, color_hex="#FF0000",
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
color_hex="#FF0000",
field_query_ref=FIELD_UNITS,
)
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
input_table="financials", input_column="Units Sold",
threshold=50000, color_hex="#00FF00",
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=50000,
color_hex="#00FF00",
field_query_ref=FIELD_UNITS,
)
@ -602,19 +614,22 @@ def test_format_background_conditional_replaces_existing(report_with_visual: Pat
def test_format_background_conditional_comparison_lte(report_with_visual: Path) -> None:
"""comparison='lte' maps to ComparisonKind=5."""
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
input_table="financials", input_column="Units Sold",
threshold=10000, color_hex="#AABBCC",
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=10000,
color_hex="#AABBCC",
comparison="lte",
field_query_ref=FIELD_UNITS,
)
data = _read_visual(report_with_visual)
entry = data["visual"]["objects"]["values"][0]
kind = (
entry["properties"]["backColor"]["solid"]["color"]["expr"]
["Conditional"]["Cases"][0]["Condition"]["Comparison"]["ComparisonKind"]
)
kind = entry["properties"]["backColor"]["solid"]["color"]["expr"]["Conditional"]["Cases"][0][
"Condition"
]["Comparison"]["ComparisonKind"]
assert kind == 5 # lte
@ -624,8 +639,12 @@ def test_format_background_conditional_invalid_comparison(
"""An unknown comparison string raises PbiCliError."""
with pytest.raises(PbiCliError):
format_background_conditional(
report_with_visual, PAGE_NAME, VISUAL_NAME,
input_table="financials", input_column="Units Sold",
threshold=100, color_hex="#000000",
report_with_visual,
PAGE_NAME,
VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100,
color_hex="#000000",
comparison="between",
)

View file

@ -27,26 +27,35 @@ def report_with_page(tmp_path: Path) -> Path:
defn = tmp_path / "Test.Report" / "definition"
defn.mkdir(parents=True)
_write(defn / "version.json", {"$schema": "...", "version": "2.0.0"})
_write(defn / "report.json", {
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
"layoutOptimization": "None",
})
_write(defn / "pages" / "pages.json", {
"$schema": "...",
"pageOrder": ["test_page"],
"activePageName": "test_page",
})
_write(
defn / "report.json",
{
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
"layoutOptimization": "None",
},
)
_write(
defn / "pages" / "pages.json",
{
"$schema": "...",
"pageOrder": ["test_page"],
"activePageName": "test_page",
},
)
page_dir = defn / "pages" / "test_page"
page_dir.mkdir(parents=True)
_write(page_dir / "page.json", {
"$schema": "...",
"name": "test_page",
"displayName": "Test Page",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
})
_write(
page_dir / "page.json",
{
"$schema": "...",
"name": "test_page",
"displayName": "Test Page",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
},
)
(page_dir / "visuals").mkdir()
return defn
@ -55,12 +64,15 @@ def report_with_page(tmp_path: Path) -> Path:
# Fix #1: Measure detection via role heuristic
# ---------------------------------------------------------------------------
class TestMeasureDetection:
def test_value_role_creates_measure_ref(self, report_with_page: Path) -> None:
"""--value bindings should produce Measure references, not Column."""
visual_add(report_with_page, "test_page", "bar_chart", name="chart1")
visual_bind(
report_with_page, "test_page", "chart1",
report_with_page,
"test_page",
"chart1",
bindings=[{"role": "value", "field": "Sales[Amount]"}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "chart1" / "visual.json"
@ -73,7 +85,9 @@ class TestMeasureDetection:
"""--category bindings should produce Column references."""
visual_add(report_with_page, "test_page", "bar_chart", name="chart2")
visual_bind(
report_with_page, "test_page", "chart2",
report_with_page,
"test_page",
"chart2",
bindings=[{"role": "category", "field": "Date[Year]"}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "chart2" / "visual.json"
@ -86,7 +100,9 @@ class TestMeasureDetection:
"""--field on card should be a Measure (Values role is the correct Desktop key)."""
visual_add(report_with_page, "test_page", "card", name="card1")
visual_bind(
report_with_page, "test_page", "card1",
report_with_page,
"test_page",
"card1",
bindings=[{"role": "field", "field": "Sales[Revenue]"}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "card1" / "visual.json"
@ -98,7 +114,9 @@ class TestMeasureDetection:
"""Explicit measure=True forces Measure even on category role."""
visual_add(report_with_page, "test_page", "bar_chart", name="chart3")
visual_bind(
report_with_page, "test_page", "chart3",
report_with_page,
"test_page",
"chart3",
bindings=[{"role": "category", "field": "Sales[Calc]", "measure": True}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "chart3" / "visual.json"
@ -111,6 +129,7 @@ class TestMeasureDetection:
# Fix #2: visual_bind merges with existing bindings
# ---------------------------------------------------------------------------
class TestBindMerge:
def test_second_bind_preserves_first(self, report_with_page: Path) -> None:
"""Calling bind twice should keep all bindings."""
@ -118,13 +137,17 @@ class TestBindMerge:
# First bind: category
visual_bind(
report_with_page, "test_page", "merged",
report_with_page,
"test_page",
"merged",
bindings=[{"role": "category", "field": "Date[Year]"}],
)
# Second bind: value
visual_bind(
report_with_page, "test_page", "merged",
report_with_page,
"test_page",
"merged",
bindings=[{"role": "value", "field": "Sales[Amount]"}],
)
@ -150,12 +173,15 @@ class TestBindMerge:
# Fix #3: Table names with spaces
# ---------------------------------------------------------------------------
class TestFieldRefParsing:
def test_table_with_spaces(self, report_with_page: Path) -> None:
"""Table[Column] notation should work with spaces in table name."""
visual_add(report_with_page, "test_page", "bar_chart", name="spaces")
result = visual_bind(
report_with_page, "test_page", "spaces",
report_with_page,
"test_page",
"spaces",
bindings=[{"role": "category", "field": "Sales Table[Region Name]"}],
)
assert result["bindings"][0]["query_ref"] == "Sales Table.Region Name"
@ -164,7 +190,9 @@ class TestFieldRefParsing:
"""Standard Table[Column] still works."""
visual_add(report_with_page, "test_page", "bar_chart", name="simple")
result = visual_bind(
report_with_page, "test_page", "simple",
report_with_page,
"test_page",
"simple",
bindings=[{"role": "category", "field": "Date[Year]"}],
)
assert result["bindings"][0]["query_ref"] == "Date.Year"
@ -174,7 +202,9 @@ class TestFieldRefParsing:
visual_add(report_with_page, "test_page", "card", name="bad")
with pytest.raises(PbiCliError, match="Table\\[Column\\]"):
visual_bind(
report_with_page, "test_page", "bad",
report_with_page,
"test_page",
"bad",
bindings=[{"role": "field", "field": "JustAName"}],
)
@ -183,6 +213,7 @@ class TestFieldRefParsing:
# Fix #4: _find_from_pbip guard
# ---------------------------------------------------------------------------
class TestPbipGuard:
def test_nonexistent_dir_returns_none(self, tmp_path: Path) -> None:
result = _find_from_pbip(tmp_path / "does_not_exist")
@ -199,6 +230,7 @@ class TestPbipGuard:
# Fix #9: report_convert overwrite guard
# ---------------------------------------------------------------------------
class TestConvertGuard:
def test_convert_blocks_overwrite(self, tmp_path: Path) -> None:
"""Second convert without --force should raise."""

View file

@ -21,15 +21,19 @@ from pbi_cli.core.pbir_path import (
# Helpers
# ---------------------------------------------------------------------------
_REPORT_JSON = json.dumps({
"$schema": "https://developer.microsoft.com/json-schemas/"
"fabric/item/report/definition/report/1.0.0/schema.json"
})
_VERSION_JSON = json.dumps({
"$schema": "https://developer.microsoft.com/json-schemas/"
"fabric/item/report/definition/version/1.0.0/schema.json",
"version": "1.0.0",
})
_REPORT_JSON = json.dumps(
{
"$schema": "https://developer.microsoft.com/json-schemas/"
"fabric/item/report/definition/report/1.0.0/schema.json"
}
)
_VERSION_JSON = json.dumps(
{
"$schema": "https://developer.microsoft.com/json-schemas/"
"fabric/item/report/definition/version/1.0.0/schema.json",
"version": "1.0.0",
}
)
def scaffold_valid_pbir(root: Path, report_name: str = "MyReport") -> Path:

View file

@ -25,36 +25,48 @@ def valid_report(tmp_path: Path) -> Path:
defn.mkdir(parents=True)
_write(defn / "version.json", {"$schema": "...", "version": "1.0.0"})
_write(defn / "report.json", {
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
"layoutOptimization": "Disabled",
})
_write(
defn / "report.json",
{
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
"layoutOptimization": "Disabled",
},
)
page_dir = defn / "pages" / "page1"
page_dir.mkdir(parents=True)
_write(page_dir / "page.json", {
"$schema": "...",
"name": "page1",
"displayName": "Page One",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
"ordinal": 0,
})
_write(defn / "pages" / "pages.json", {
"$schema": "...",
"pageOrder": ["page1"],
})
_write(
page_dir / "page.json",
{
"$schema": "...",
"name": "page1",
"displayName": "Page One",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
"ordinal": 0,
},
)
_write(
defn / "pages" / "pages.json",
{
"$schema": "...",
"pageOrder": ["page1"],
},
)
vis_dir = page_dir / "visuals" / "vis1"
vis_dir.mkdir(parents=True)
_write(vis_dir / "visual.json", {
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {"visualType": "barChart", "query": {}, "objects": {}},
})
_write(
vis_dir / "visual.json",
{
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {"visualType": "barChart", "query": {}, "objects": {}},
},
)
return defn
@ -92,63 +104,81 @@ class TestValidateReportFull:
assert any("Invalid JSON" in e["message"] for e in result["errors"])
def test_missing_theme_collection(self, valid_report: Path) -> None:
_write(valid_report / "report.json", {
"$schema": "...",
"layoutOptimization": "Disabled",
})
_write(
valid_report / "report.json",
{
"$schema": "...",
"layoutOptimization": "Disabled",
},
)
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("themeCollection" in e["message"] for e in result["errors"])
def test_missing_layout_optimization(self, valid_report: Path) -> None:
_write(valid_report / "report.json", {
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
})
_write(
valid_report / "report.json",
{
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
},
)
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("layoutOptimization" in e["message"] for e in result["errors"])
def test_page_missing_required_fields(self, valid_report: Path) -> None:
_write(valid_report / "pages" / "page1" / "page.json", {
"$schema": "...",
"name": "page1",
})
_write(
valid_report / "pages" / "page1" / "page.json",
{
"$schema": "...",
"name": "page1",
},
)
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("displayName" in e["message"] for e in result["errors"])
assert any("displayOption" in e["message"] for e in result["errors"])
def test_page_invalid_display_option(self, valid_report: Path) -> None:
_write(valid_report / "pages" / "page1" / "page.json", {
"$schema": "...",
"name": "page1",
"displayName": "P1",
"displayOption": "InvalidOption",
"width": 1280,
"height": 720,
})
_write(
valid_report / "pages" / "page1" / "page.json",
{
"$schema": "...",
"name": "page1",
"displayName": "P1",
"displayOption": "InvalidOption",
"width": 1280,
"height": 720,
},
)
result = validate_report_full(valid_report)
assert any("Unknown displayOption" in w["message"] for w in result["warnings"])
def test_visual_missing_position(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
_write(vis_path, {
"$schema": "...",
"name": "vis1",
"visual": {"visualType": "barChart"},
})
_write(
vis_path,
{
"$schema": "...",
"name": "vis1",
"visual": {"visualType": "barChart"},
},
)
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("position" in e["message"] for e in result["errors"])
def test_visual_missing_name(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
_write(vis_path, {
"$schema": "...",
"position": {"x": 0, "y": 0, "width": 100, "height": 100},
"visual": {"visualType": "card"},
})
_write(
vis_path,
{
"$schema": "...",
"position": {"x": 0, "y": 0, "width": 100, "height": 100},
"visual": {"visualType": "card"},
},
)
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("name" in e["message"] for e in result["errors"])
@ -156,24 +186,30 @@ class TestValidateReportFull:
class TestPageOrderConsistency:
def test_phantom_page_in_order(self, valid_report: Path) -> None:
_write(valid_report / "pages" / "pages.json", {
"$schema": "...",
"pageOrder": ["page1", "ghost_page"],
})
_write(
valid_report / "pages" / "pages.json",
{
"$schema": "...",
"pageOrder": ["page1", "ghost_page"],
},
)
result = validate_report_full(valid_report)
assert any("ghost_page" in w["message"] for w in result["warnings"])
def test_unlisted_page_info(self, valid_report: Path) -> None:
page2 = valid_report / "pages" / "page2"
page2.mkdir(parents=True)
_write(page2 / "page.json", {
"$schema": "...",
"name": "page2",
"displayName": "Page Two",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
})
_write(
page2 / "page.json",
{
"$schema": "...",
"name": "page2",
"displayName": "Page Two",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
},
)
result = validate_report_full(valid_report)
assert any("page2" in i["message"] and "not listed" in i["message"] for i in result["info"])
@ -182,12 +218,15 @@ class TestVisualNameUniqueness:
def test_duplicate_visual_names(self, valid_report: Path) -> None:
vis2_dir = valid_report / "pages" / "page1" / "visuals" / "vis2"
vis2_dir.mkdir(parents=True)
_write(vis2_dir / "visual.json", {
"$schema": "...",
"name": "vis1", # Duplicate of vis1
"position": {"x": 0, "y": 0, "width": 100, "height": 100},
"visual": {"visualType": "card"},
})
_write(
vis2_dir / "visual.json",
{
"$schema": "...",
"name": "vis1", # Duplicate of vis1
"position": {"x": 0, "y": 0, "width": 100, "height": 100},
"visual": {"visualType": "card"},
},
)
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("Duplicate visual name" in e["message"] for e in result["errors"])
@ -196,62 +235,76 @@ class TestVisualNameUniqueness:
class TestBindingsAgainstModel:
def test_valid_binding_passes(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
_write(vis_path, {
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {
"visualType": "barChart",
"query": {
"Commands": [{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
"Select": [{
"Column": {
"Expression": {"SourceRef": {"Source": "s"}},
"Property": "Region",
},
"Name": "s.Region",
}],
_write(
vis_path,
{
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {
"visualType": "barChart",
"query": {
"Commands": [
{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
"Select": [
{
"Column": {
"Expression": {"SourceRef": {"Source": "s"}},
"Property": "Region",
},
"Name": "s.Region",
}
],
}
}
}
}
}],
],
},
},
},
})
)
model = [{"name": "Sales", "columns": [{"name": "Region"}], "measures": []}]
findings = validate_bindings_against_model(valid_report, model)
assert len(findings) == 0
def test_invalid_binding_warns(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
_write(vis_path, {
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {
"visualType": "barChart",
"query": {
"Commands": [{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
"Select": [{
"Column": {
"Expression": {"SourceRef": {"Source": "s"}},
"Property": "NonExistent",
},
"Name": "s.NonExistent",
}],
_write(
vis_path,
{
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {
"visualType": "barChart",
"query": {
"Commands": [
{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
"Select": [
{
"Column": {
"Expression": {"SourceRef": {"Source": "s"}},
"Property": "NonExistent",
},
"Name": "s.NonExistent",
}
],
}
}
}
}
}],
],
},
},
},
})
)
model = [{"name": "Sales", "columns": [{"name": "Region"}], "measures": []}]
findings = validate_bindings_against_model(valid_report, model)
assert len(findings) == 1
@ -260,31 +313,38 @@ class TestBindingsAgainstModel:
def test_measure_binding(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
_write(vis_path, {
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {
"visualType": "card",
"query": {
"Commands": [{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
"Select": [{
"Measure": {
"Expression": {"SourceRef": {"Source": "s"}},
"Property": "Total Revenue",
},
"Name": "s.Total Revenue",
}],
_write(
vis_path,
{
"$schema": "...",
"name": "vis1",
"position": {"x": 0, "y": 0, "width": 400, "height": 300},
"visual": {
"visualType": "card",
"query": {
"Commands": [
{
"SemanticQueryDataShapeCommand": {
"Query": {
"Version": 2,
"From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
"Select": [
{
"Measure": {
"Expression": {"SourceRef": {"Source": "s"}},
"Property": "Total Revenue",
},
"Name": "s.Total Revenue",
}
],
}
}
}
}
}],
],
},
},
},
})
)
model = [{"name": "Sales", "columns": [], "measures": [{"name": "Total Revenue"}]}]
findings = validate_bindings_against_model(valid_report, model)
assert len(findings) == 0

View file

@ -24,63 +24,78 @@ def preview_report(tmp_path: Path) -> Path:
defn = tmp_path / "Test.Report" / "definition"
defn.mkdir(parents=True)
_write(defn / "report.json", {
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
"layoutOptimization": "Disabled",
})
_write(
defn / "report.json",
{
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
"layoutOptimization": "Disabled",
},
)
_write(defn / "version.json", {"$schema": "...", "version": "1.0.0"})
_write(defn / "pages" / "pages.json", {
"$schema": "...",
"pageOrder": ["overview"],
})
_write(
defn / "pages" / "pages.json",
{
"$schema": "...",
"pageOrder": ["overview"],
},
)
page_dir = defn / "pages" / "overview"
page_dir.mkdir(parents=True)
_write(page_dir / "page.json", {
"$schema": "...",
"name": "overview",
"displayName": "Executive Overview",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
"ordinal": 0,
})
_write(
page_dir / "page.json",
{
"$schema": "...",
"name": "overview",
"displayName": "Executive Overview",
"displayOption": "FitToPage",
"width": 1280,
"height": 720,
"ordinal": 0,
},
)
# Bar chart visual
bar_dir = page_dir / "visuals" / "bar1"
bar_dir.mkdir(parents=True)
_write(bar_dir / "visual.json", {
"$schema": "...",
"name": "bar1",
"position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0},
"visual": {
"visualType": "barChart",
"query": {
"queryState": {
"Category": {"projections": [{"queryRef": "g.Region", "field": {}}]},
"Y": {"projections": [{"queryRef": "s.Amount", "field": {}}]},
_write(
bar_dir / "visual.json",
{
"$schema": "...",
"name": "bar1",
"position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0},
"visual": {
"visualType": "barChart",
"query": {
"queryState": {
"Category": {"projections": [{"queryRef": "g.Region", "field": {}}]},
"Y": {"projections": [{"queryRef": "s.Amount", "field": {}}]},
},
},
},
},
})
)
# Card visual
card_dir = page_dir / "visuals" / "card1"
card_dir.mkdir(parents=True)
_write(card_dir / "visual.json", {
"$schema": "...",
"name": "card1",
"position": {"x": 500, "y": 50, "width": 200, "height": 120, "z": 1},
"visual": {
"visualType": "card",
"query": {
"queryState": {
"Fields": {"projections": [{"queryRef": "s.Revenue", "field": {}}]},
_write(
card_dir / "visual.json",
{
"$schema": "...",
"name": "card1",
"position": {"x": 500, "y": 50, "width": 200, "height": 120, "z": 1},
"visual": {
"visualType": "card",
"query": {
"queryState": {
"Fields": {"projections": [{"queryRef": "s.Revenue", "field": {}}]},
},
},
},
},
})
)
return defn
@ -122,11 +137,14 @@ class TestRenderReport:
def test_empty_report(self, tmp_path: Path) -> None:
defn = tmp_path / "Empty.Report" / "definition"
defn.mkdir(parents=True)
_write(defn / "report.json", {
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "Default"}},
"layoutOptimization": "Disabled",
})
_write(
defn / "report.json",
{
"$schema": "...",
"themeCollection": {"baseTheme": {"name": "Default"}},
"layoutOptimization": "Disabled",
},
)
html = render_report(defn)
assert "No pages" in html

View file

@ -87,15 +87,18 @@ def _make_page(
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,
})
_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)
@ -103,22 +106,25 @@ def _make_page(
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": []},
_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": {},
},
"objects": {},
},
})
)
# ---------------------------------------------------------------------------
@ -151,38 +157,50 @@ def sample_report(tmp_path: Path) -> Path:
pages_dir.mkdir(parents=True)
# version.json
_write(definition_dir / "version.json", {
"$schema": _SCHEMA_VERSION,
"version": "1.0.0",
})
_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",
_write(
definition_dir / "report.json",
{
"$schema": _SCHEMA_REPORT,
"themeCollection": {
"baseTheme": {
"name": "CY24SU06",
"reportVersionAtImport": "5.55",
"type": "SharedResources",
},
},
"layoutOptimization": "Disabled",
},
"layoutOptimization": "Disabled",
})
)
# pages.json
_write(pages_dir / "pages.json", {
"$schema": _SCHEMA_PAGES_METADATA,
"pageOrder": ["page1"],
})
_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",
})
_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)
@ -227,15 +245,20 @@ class TestReportInfo:
"""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"
_write(
definition_dir / "report.json",
{
"$schema": _SCHEMA_REPORT,
"themeCollection": {
"baseTheme": {
"name": "CY24SU06",
"reportVersionAtImport": "5.55",
"type": "SharedResources",
},
},
"layoutOptimization": "Disabled",
},
"layoutOptimization": "Disabled",
})
)
result = report_info(definition_dir)
assert result["page_count"] == 0
@ -261,10 +284,13 @@ class TestReportInfo:
"""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",
})
_write(
definition_dir / "report.json",
{
"$schema": _SCHEMA_REPORT,
"layoutOptimization": "Disabled",
},
)
result = report_info(definition_dir)
assert result["theme"] == "Default"
@ -319,8 +345,7 @@ class TestReportCreate:
data = _read(pbip_file)
assert data["version"] == "1.0"
assert any(
a.get("report", {}).get("path") == "SalesReport.Report"
for a in data["artifacts"]
a.get("report", {}).get("path") == "SalesReport.Report" for a in data["artifacts"]
)
def test_report_create_returns_definition_path(self, tmp_path: Path) -> None:
@ -382,11 +407,14 @@ class TestReportValidate:
"""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",
})
_write(
definition_dir / "report.json",
{
"$schema": _SCHEMA_REPORT,
"themeCollection": {"baseTheme": {}},
"layoutOptimization": "Disabled",
},
)
result = report_validate(definition_dir)
assert result["valid"] is False
@ -402,10 +430,13 @@ class TestReportValidate:
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",
})
_write(
sample_report / "report.json",
{
"$schema": _SCHEMA_REPORT,
"layoutOptimization": "Disabled",
},
)
result = report_validate(sample_report)
assert result["valid"] is False
@ -413,10 +444,13 @@ class TestReportValidate:
def test_report_validate_missing_layout_optimization(self, sample_report: Path) -> None:
"""report.json without 'layoutOptimization' is invalid."""
_write(sample_report / "report.json", {
"$schema": _SCHEMA_REPORT,
"themeCollection": {"baseTheme": {}},
})
_write(
sample_report / "report.json",
{
"$schema": _SCHEMA_REPORT,
"themeCollection": {"baseTheme": {}},
},
)
result = report_validate(sample_report)
assert result["valid"] is False
@ -468,11 +502,14 @@ class TestPageList:
"""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",
})
_write(
definition_dir / "report.json",
{
"$schema": _SCHEMA_REPORT,
"themeCollection": {"baseTheme": {}},
"layoutOptimization": "Disabled",
},
)
result = page_list(definition_dir)
assert result == []
@ -492,10 +529,13 @@ class TestPageList:
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"],
})
_write(
pages_dir / "pages.json",
{
"$schema": _SCHEMA_PAGES_METADATA,
"pageOrder": ["page2", "page1"],
},
)
result = page_list(sample_report)
assert result[0]["name"] == "page2"
@ -519,20 +559,21 @@ class TestPageList:
# 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": {}},
})
_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:
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"
@ -579,9 +620,7 @@ class TestPageAdd:
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"
)
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"
@ -725,9 +764,7 @@ class TestPageGet:
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"}]
}
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})
@ -737,9 +774,7 @@ class TestPageGet:
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"}
]
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})
@ -747,9 +782,7 @@ class TestPageGet:
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:
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
@ -783,23 +816,22 @@ 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",
})
_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:
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:
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"
@ -820,9 +852,7 @@ class TestThemeSet:
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:
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)
@ -847,9 +877,7 @@ class TestThemeSet:
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:
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)
@ -862,9 +890,7 @@ class TestThemeSet:
# 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:
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"):
@ -917,11 +943,14 @@ class TestThemeGet:
"""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",
})
_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

View file

@ -12,8 +12,6 @@ from __future__ import annotations
import importlib.resources
import re
import yaml
def _load_skills() -> dict[str, str]:
"""Load all skill names and descriptions from bundled skills."""
@ -24,8 +22,18 @@ def _load_skills() -> dict[str, str]:
content = (item / "SKILL.md").read_text(encoding="utf-8")
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if match:
fm = yaml.safe_load(match.group(1))
skills[item.name] = fm.get("description", "").lower()
# Parse description from YAML frontmatter without pyyaml
# Handles both single-line and multi-line (>) formats
fm_text = match.group(1)
multi = re.search(r"description:\s*>?\s*\n((?:\s+.*\n)*)", fm_text)
single = re.search(r"description:\s+(.+)", fm_text)
if multi and multi.group(1).strip():
desc = " ".join(
line.strip() for line in multi.group(1).splitlines() if line.strip()
)
skills[item.name] = desc.lower()
elif single:
skills[item.name] = single.group(1).strip().lower()
return skills

View file

@ -81,10 +81,7 @@ _TOTAL_REVENUE_BLOCK = (
"\n\t\tlineageTag: msr-001\n"
)
_NEW_COL_SNIPPET = (
"\n\tcolumn Region"
"\n\t\tdataType: string"
"\n\t\tsummarizeBy: none"
"\n\t\tsourceColumn: Region\n"
"\n\tcolumn Region\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: Region\n"
)
_AMOUNT_COL_BLOCK = (
"\n\tcolumn Amount"
@ -94,19 +91,13 @@ _AMOUNT_COL_BLOCK = (
"\n\t\tsourceColumn: Amount\n"
)
_NEW_REL_SNIPPET = (
"\nrelationship abc-def-999"
"\n\tfromColumn: Sales.RegionID"
"\n\ttoColumn: Region.ID\n"
"\nrelationship abc-def-999\n\tfromColumn: Sales.RegionID\n\ttoColumn: Region.ID\n"
)
_TRIMMED_RELS = (
"relationship abc-def-111"
"\n\tfromColumn: Sales.ProductID"
"\n\ttoColumn: Product.ProductID\n"
"relationship abc-def-111\n\tfromColumn: Sales.ProductID\n\ttoColumn: Product.ProductID\n"
)
_REL_222_BASE = (
"relationship abc-def-222"
"\n\tfromColumn: Sales.CustomerID"
"\n\ttoColumn: Customer.CustomerID"
"relationship abc-def-222\n\tfromColumn: Sales.CustomerID\n\ttoColumn: Customer.CustomerID"
)
_REL_222_CHANGED = (
"relationship abc-def-222"

View file

@ -171,9 +171,7 @@ def test_visual_add_table(report_with_page: Path) -> None:
assert result["status"] == "created"
assert result["visual_type"] == "tableEx"
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / "mytable" / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "mytable" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
assert data["visual"]["visualType"] == "tableEx"
@ -185,9 +183,7 @@ def test_visual_add_matrix(report_with_page: Path) -> None:
assert result["status"] == "created"
assert result["visual_type"] == "pivotTable"
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / "mymatrix" / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "mymatrix" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
assert data["visual"]["visualType"] == "pivotTable"
@ -215,14 +211,7 @@ def test_visual_add_custom_position(report_with_page: Path) -> None:
assert result["width"] == 600.0
assert result["height"] == 450.0
vfile = (
report_with_page
/ "pages"
/ "test_page"
/ "visuals"
/ "positioned"
/ "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "positioned" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
pos = data["position"]
assert pos["x"] == 100.0
@ -374,9 +363,7 @@ def test_visual_update_position(report_with_page: Path) -> None:
assert result["position"]["height"] == 250.0
# Confirm the file on disk reflects the change
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / "movable" / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "movable" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
pos = data["position"]
assert pos["x"] == 50.0
@ -470,14 +457,7 @@ def test_visual_bind_category_value(report_with_page: Path) -> None:
assert "Y" in roles_applied
# Verify the projections were written into visual.json
vfile = (
report_with_page
/ "pages"
/ "test_page"
/ "visuals"
/ "bound_bar"
/ "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "bound_bar" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
query_state = data["visual"]["query"]["queryState"]
@ -518,14 +498,7 @@ def test_visual_bind_multiple_values(report_with_page: Path) -> None:
assert all(b["role"] == "Values" for b in result["bindings"])
# Confirm all three projections landed in the Values role
vfile = (
report_with_page
/ "pages"
/ "test_page"
/ "visuals"
/ "bound_table"
/ "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "bound_table" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
projections = data["visual"]["query"]["queryState"]["Values"]["projections"]
assert len(projections) == 3
@ -562,18 +535,14 @@ def test_visual_bind_multiple_values(report_with_page: Path) -> None:
("combo_chart", "lineStackedColumnComboChart"),
],
)
def test_visual_add_new_types(
report_with_page: Path, alias: str, expected_type: str
) -> None:
def test_visual_add_new_types(report_with_page: Path, alias: str, expected_type: str) -> None:
"""visual_add resolves v3.1.0 type aliases and writes correct visualType."""
result = visual_add(report_with_page, "test_page", alias, name=f"v_{alias}")
assert result["status"] == "created"
assert result["visual_type"] == expected_type
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / f"v_{alias}" / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / f"v_{alias}" / "visual.json"
assert vfile.exists()
data = json.loads(vfile.read_text(encoding="utf-8"))
assert data["visual"]["visualType"] == expected_type
@ -655,10 +624,9 @@ def test_visual_add_new_types_default_sizes(report_with_page: Path) -> None:
# Task 1 tests -- cardVisual and actionButton
# ---------------------------------------------------------------------------
def test_visual_add_card_visual(report_with_page: Path) -> None:
result = visual_add(
report_with_page, "test_page", "cardVisual", x=10, y=10
)
result = visual_add(report_with_page, "test_page", "cardVisual", x=10, y=10)
assert result["status"] == "created"
assert result["visual_type"] == "cardVisual"
vdir = report_with_page / "pages" / "test_page" / "visuals" / result["name"]
@ -671,16 +639,12 @@ def test_visual_add_card_visual(report_with_page: Path) -> None:
def test_visual_add_card_visual_alias(report_with_page: Path) -> None:
result = visual_add(
report_with_page, "test_page", "card_visual", x=10, y=10
)
result = visual_add(report_with_page, "test_page", "card_visual", x=10, y=10)
assert result["visual_type"] == "cardVisual"
def test_visual_add_action_button(report_with_page: Path) -> None:
result = visual_add(
report_with_page, "test_page", "actionButton", x=0, y=0
)
result = visual_add(report_with_page, "test_page", "actionButton", x=0, y=0)
assert result["status"] == "created"
assert result["visual_type"] == "actionButton"
vdir = report_with_page / "pages" / "test_page" / "visuals" / result["name"]
@ -693,9 +657,7 @@ def test_visual_add_action_button(report_with_page: Path) -> None:
def test_visual_add_action_button_aliases(report_with_page: Path) -> None:
for alias in ("action_button", "button"):
result = visual_add(
report_with_page, "test_page", alias, x=0, y=0
)
result = visual_add(report_with_page, "test_page", alias, x=0, y=0)
assert result["visual_type"] == "actionButton"
@ -768,8 +730,9 @@ def test_visual_set_container_border_show(
visual_set_container(defn, "test_page", vname, border_show=True)
vfile = defn / "pages" / "test_page" / "visuals" / vname / "visual.json"
data = json.loads(vfile.read_text())
val = data["visual"]["visualContainerObjects"]["border"][0][
"properties"]["show"]["expr"]["Literal"]["Value"]
val = data["visual"]["visualContainerObjects"]["border"][0]["properties"]["show"]["expr"][
"Literal"
]["Value"]
assert val == "true"
@ -777,9 +740,7 @@ def test_visual_set_container_raises_for_missing_visual(
report_with_page: Path,
) -> None:
with pytest.raises(PbiCliError):
visual_set_container(
report_with_page, "test_page", "nonexistent_visual", border_show=False
)
visual_set_container(report_with_page, "test_page", "nonexistent_visual", border_show=False)
def test_visual_set_container_no_op_returns_no_op_status(
@ -797,10 +758,7 @@ def test_visual_set_container_no_op_returns_no_op_status(
def test_visual_add_uses_correct_schema_version(report_with_page: Path) -> None:
result = visual_add(report_with_page, "test_page", "barChart", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals"
/ result["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / result["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "2.7.0" in data["$schema"]
assert "1.5.0" not in data["$schema"]
@ -816,11 +774,14 @@ def test_visual_list_tags_group_containers_as_group(report_with_page: Path) -> N
visuals_dir = report_with_page / "pages" / "test_page" / "visuals"
grp_dir = visuals_dir / "grp1"
grp_dir.mkdir()
_write_json(grp_dir / "visual.json", {
"$schema": "https://example.com/schema",
"name": "grp1",
"visualGroup": {"displayName": "Header Group", "visuals": []}
})
_write_json(
grp_dir / "visual.json",
{
"$schema": "https://example.com/schema",
"name": "grp1",
"visualGroup": {"displayName": "Header Group", "visuals": []},
},
)
results = visual_list(report_with_page, "test_page")
grp = next(r for r in results if r["name"] == "grp1")
assert grp["visual_type"] == "group"
@ -831,15 +792,16 @@ def test_visual_list_tags_group_containers_as_group(report_with_page: Path) -> N
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("vtype,alias", [
("clusteredColumnChart", "clustered_column"),
("clusteredBarChart", "clustered_bar"),
("textSlicer", "text_slicer"),
("listSlicer", "list_slicer"),
])
def test_visual_add_new_v35_types(
report_with_page: Path, vtype: str, alias: str
) -> None:
@pytest.mark.parametrize(
"vtype,alias",
[
("clusteredColumnChart", "clustered_column"),
("clusteredBarChart", "clustered_bar"),
("textSlicer", "text_slicer"),
("listSlicer", "list_slicer"),
],
)
def test_visual_add_new_v35_types(report_with_page: Path, vtype: str, alias: str) -> None:
r = visual_add(report_with_page, "test_page", vtype, x=0, y=0)
assert r["visual_type"] == vtype
r2 = visual_add(report_with_page, "test_page", alias, x=50, y=0)
@ -848,10 +810,7 @@ def test_visual_add_new_v35_types(
def test_list_slicer_template_has_active_flag(report_with_page: Path) -> None:
r = visual_add(report_with_page, "test_page", "listSlicer", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals"
/ r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
values = data["visual"]["query"]["queryState"]["Values"]
assert values.get("active") is True
@ -862,13 +821,16 @@ def test_list_slicer_template_has_active_flag(report_with_page: Path) -> None:
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("vtype,alias", [
("image", "img"),
("textbox", "text_box"),
("pageNavigator", "page_navigator"),
("pageNavigator", "page_nav"),
("pageNavigator", "navigator"),
])
@pytest.mark.parametrize(
"vtype,alias",
[
("image", "img"),
("textbox", "text_box"),
("pageNavigator", "page_navigator"),
("pageNavigator", "page_nav"),
("pageNavigator", "navigator"),
],
)
def test_visual_add_v36_alias_types(report_with_page: Path, vtype: str, alias: str) -> None:
r = visual_add(report_with_page, "test_page", alias, x=0, y=0)
assert r["visual_type"] == vtype
@ -878,9 +840,7 @@ def test_visual_add_v36_alias_types(report_with_page: Path, vtype: str, alias: s
def test_visual_add_no_query_v36(report_with_page: Path, vtype: str) -> None:
"""No-query types must not have a 'query' key in the written visual.json."""
r = visual_add(report_with_page, "test_page", vtype, x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "query" not in data["visual"]
assert data["$schema"].endswith("2.7.0/schema.json")
@ -890,9 +850,7 @@ def test_visual_add_no_query_v36(report_with_page: Path, vtype: str) -> None:
def test_insert_visual_button_how_created(report_with_page: Path, vtype: str) -> None:
"""image, shape, pageNavigator must carry howCreated at top level."""
r = visual_add(report_with_page, "test_page", vtype, x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert data.get("howCreated") == "InsertVisualButton"
@ -900,9 +858,7 @@ def test_insert_visual_button_how_created(report_with_page: Path, vtype: str) ->
def test_textbox_no_how_created(report_with_page: Path) -> None:
"""textbox is a content visual -- no howCreated key."""
r = visual_add(report_with_page, "test_page", "textbox", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "howCreated" not in data
@ -912,9 +868,15 @@ def test_textbox_no_how_created(report_with_page: Path) -> None:
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("alias", [
"advancedSlicerVisual", "advanced_slicer", "adv_slicer", "tile_slicer",
])
@pytest.mark.parametrize(
"alias",
[
"advancedSlicerVisual",
"advanced_slicer",
"adv_slicer",
"tile_slicer",
],
)
def test_advanced_slicer_aliases(report_with_page: Path, alias: str) -> None:
r = visual_add(report_with_page, "test_page", alias, x=0, y=0)
assert r["visual_type"] == "advancedSlicerVisual"
@ -922,9 +884,7 @@ def test_advanced_slicer_aliases(report_with_page: Path, alias: str) -> None:
def test_advanced_slicer_has_values_querystate(report_with_page: Path) -> None:
r = visual_add(report_with_page, "test_page", "advancedSlicerVisual", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "query" in data["visual"]
assert "Values" in data["visual"]["query"]["queryState"]
@ -939,9 +899,7 @@ def test_advanced_slicer_has_values_querystate(report_with_page: Path) -> None:
def test_card_template_uses_values_role(report_with_page: Path) -> None:
"""card visual queryState must use 'Values' not 'Fields' (Desktop compat)."""
r = visual_add(report_with_page, "test_page", "card", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "Values" in qs
@ -952,9 +910,7 @@ def test_card_template_uses_values_role(report_with_page: Path) -> None:
def test_multi_row_card_template_uses_values_role(report_with_page: Path) -> None:
"""multiRowCard visual queryState must use 'Values' not 'Fields'."""
r = visual_add(report_with_page, "test_page", "multiRowCard", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "Values" in qs
@ -969,9 +925,7 @@ def test_multi_row_card_template_uses_values_role(report_with_page: Path) -> Non
def test_kpi_template_has_trend_line_role(report_with_page: Path) -> None:
"""kpi template must include TrendLine queryState key (confirmed from Desktop)."""
r = visual_add(report_with_page, "test_page", "kpi", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "TrendLine" in qs
@ -983,9 +937,7 @@ def test_kpi_template_has_trend_line_role(report_with_page: Path) -> None:
def test_gauge_template_has_max_value_role(report_with_page: Path) -> None:
"""gauge template must include MaxValue queryState key (confirmed from Desktop)."""
r = visual_add(report_with_page, "test_page", "gauge", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "MaxValue" in qs
@ -993,23 +945,31 @@ def test_gauge_template_has_max_value_role(report_with_page: Path) -> None:
assert "Y" in qs
@pytest.mark.parametrize("alias,expected_role", [
("trend_line", "TrendLine"),
("trend", "TrendLine"),
("goal", "Goal"),
])
@pytest.mark.parametrize(
"alias,expected_role",
[
("trend_line", "TrendLine"),
("trend", "TrendLine"),
("goal", "Goal"),
],
)
def test_kpi_role_aliases(alias: str, expected_role: str) -> None:
from pbi_cli.core.visual_backend import ROLE_ALIASES
assert ROLE_ALIASES["kpi"][alias] == expected_role
@pytest.mark.parametrize("alias,expected_role", [
("max", "MaxValue"),
("max_value", "MaxValue"),
("target", "MaxValue"),
])
@pytest.mark.parametrize(
"alias,expected_role",
[
("max", "MaxValue"),
("max_value", "MaxValue"),
("target", "MaxValue"),
],
)
def test_gauge_role_aliases(alias: str, expected_role: str) -> None:
from pbi_cli.core.visual_backend import ROLE_ALIASES
assert ROLE_ALIASES["gauge"][alias] == expected_role
@ -1055,9 +1015,7 @@ def test_azure_map_aliases(report_with_page: Path, alias: str) -> None:
def test_azure_map_has_category_and_size_roles(report_with_page: Path) -> None:
r = visual_add(report_with_page, "test_page", "azureMap", x=0, y=0)
vfile = (
report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
qs = data["visual"]["query"]["queryState"]
assert "Category" in qs

View file

@ -123,15 +123,11 @@ def test_visual_calc_add_correct_structure(
"""Added projection has correct NativeVisualCalculation fields."""
definition, page, visual = visual_on_page
visual_calc_add(
definition, page, visual, "Running sum", "RUNNINGSUM([Sum of Sales])", role="Y"
)
visual_calc_add(definition, page, visual, "Running sum", "RUNNINGSUM([Sum of Sales])", role="Y")
data = _read_json(_vfile(definition, page, visual))
projections = data["visual"]["query"]["queryState"]["Y"]["projections"]
nvc_proj = next(
p for p in projections if "NativeVisualCalculation" in p.get("field", {})
)
nvc_proj = next(p for p in projections if "NativeVisualCalculation" in p.get("field", {}))
nvc = nvc_proj["field"]["NativeVisualCalculation"]
assert nvc["Language"] == "dax"
@ -154,9 +150,7 @@ def test_visual_calc_add_query_refs(
data = _read_json(_vfile(definition, page, visual))
projections = data["visual"]["query"]["queryState"]["Y"]["projections"]
nvc_proj = next(
p for p in projections if "NativeVisualCalculation" in p.get("field", {})
)
nvc_proj = next(p for p in projections if "NativeVisualCalculation" in p.get("field", {}))
assert nvc_proj["queryRef"] == "select"
assert nvc_proj["nativeQueryRef"] == "My Calc"
@ -296,9 +290,7 @@ def test_visual_calc_delete_removes_projection(
data = _read_json(_vfile(definition, page, visual))
projections = data["visual"]["query"]["queryState"]["Y"]["projections"]
nvc_projections = [
p for p in projections if "NativeVisualCalculation" in p.get("field", {})
]
nvc_projections = [p for p in projections if "NativeVisualCalculation" in p.get("field", {})]
assert nvc_projections == []