mirror of
https://github.com/MinaSaad1/pbi-cli
synced 2026-04-21 13:37:19 +00:00
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:
parent
3eb68c56d3
commit
5acb3f33e3
31 changed files with 1258 additions and 1086 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue