fix: correct 7 PBIR report-layer issues found during Desktop testing

- visual bind: remove legacy Commands/SemanticQueryDataShapeCommand block
  (PBIR 2.7.0 uses additionalProperties:false -- Commands is a hard schema error)
- visual bind: add active:true to column (category/row/detail) projections
  so Desktop treats the field as the active axis
- visual add: remove empty "objects:{}" from all 32 visual templates
  (noisy and rejected by strict schema validators)
- visual add: write position coordinates as integers not floats
  (Desktop normalises to int; 320.0 vs 320 caused inconsistency)
- report set-background: always write transparency:0 alongside color
  (Desktop defaults missing transparency to 100 = fully invisible)
- report validate: drop false-positive layoutOptimization required error
  (real Microsoft 3.2.0 schema does not require this field)
- all write commands: add --no-sync flag to report/visual/filters/bookmarks
  groups to suppress per-command Desktop reload during scripted builds;
  use pbi report reload for a single sync at the end
This commit is contained in:
MinaSaad1 2026-04-07 17:13:41 +02:00
parent 63f4738a2e
commit 895e90d710
46 changed files with 144 additions and 173 deletions

View file

@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.10.6] - 2026-04-07
### Fixed
- `visual bind` no longer writes the legacy `Commands` block (SemanticQueryDataShapeCommand) to `visual.json`. PBIR 2.7.0 uses `additionalProperties: false` on the query object, so the `Commands` field is a hard schema violation. Only `queryState` projections are now written.
- `pbi report validate` and the full PBIR validator no longer flag a missing `layoutOptimization` field as an error. The real Microsoft schema does not list it as required; the previous check was against a stale internal schema.
- `pbi report set-background` now always writes `transparency: 0` alongside the color. Power BI Desktop defaults a missing `transparency` property to 100 (fully invisible), making the color silently unrendered. The new `--transparency` flag (0-100, default 0) lets callers override for semi-transparent backgrounds.
### Added
- `--no-sync` flag on `report`, `visual`, `filters`, and `bookmarks` command groups. Suppresses the per-command Desktop auto-sync for scripted multi-step builds. Use `pbi report reload` for a single explicit sync at the end of the script.
## [3.10.5] - 2026-04-06 ## [3.10.5] - 2026-04-06
### Fixed ### Fixed

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pbi-cli-tool" name = "pbi-cli-tool"
version = "3.10.5" version = "3.10.6"
description = "CLI for Power BI semantic models and PBIR reports - direct .NET connection for token-efficient AI agent usage" description = "CLI for Power BI semantic models and PBIR reports - direct .NET connection for token-efficient AI agent usage"
readme = "README.pypi.md" readme = "README.pypi.md"
license = "MIT AND LicenseRef-Microsoft-AS-Client-Libraries" license = "MIT AND LicenseRef-Microsoft-AS-Client-Libraries"

View file

@ -62,7 +62,7 @@ def run_command(
def _is_report_write(result: Any) -> bool: def _is_report_write(result: Any) -> bool:
"""Check if the result indicates a report-layer write.""" """Check if the result indicates a report-layer write that should trigger sync."""
if not isinstance(result, dict): if not isinstance(result, dict):
return False return False
status = result.get("status", "") status = result.get("status", "")
@ -74,11 +74,13 @@ def _is_report_write(result: Any) -> bool:
if click_ctx is None: if click_ctx is None:
return False return False
# Walk up to the group to find report_path # Walk up to the group to find report_path; also check for --no-sync flag
parent = click_ctx.parent parent = click_ctx.parent
while parent is not None: while parent is not None:
obj = parent.obj obj = parent.obj
if isinstance(obj, dict) and "report_path" in obj: if isinstance(obj, dict) and "report_path" in obj:
if obj.get("no_sync", False):
return False
return True return True
parent = parent.parent parent = parent.parent
return False return False

View file

@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context
default=None, default=None,
help="Path to .Report folder (auto-detected from CWD if omitted).", help="Path to .Report folder (auto-detected from CWD if omitted).",
) )
@click.option(
"--no-sync",
is_flag=True,
default=False,
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
)
@click.pass_context @click.pass_context
def bookmarks(ctx: click.Context, path: str | None) -> None: def bookmarks(ctx: click.Context, path: str | None, no_sync: bool) -> None:
"""Manage report bookmarks.""" """Manage report bookmarks."""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["report_path"] = path ctx.obj["report_path"] = path
ctx.obj["no_sync"] = no_sync
@bookmarks.command(name="list") @bookmarks.command(name="list")

View file

@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context
default=None, default=None,
help="Path to .Report folder (auto-detected from CWD if omitted).", help="Path to .Report folder (auto-detected from CWD if omitted).",
) )
@click.option(
"--no-sync",
is_flag=True,
default=False,
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
)
@click.pass_context @click.pass_context
def filters(ctx: click.Context, path: str | None) -> None: def filters(ctx: click.Context, path: str | None, no_sync: bool) -> None:
"""Manage page and visual filters.""" """Manage page and visual filters."""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["report_path"] = path ctx.obj["report_path"] = path
ctx.obj["no_sync"] = no_sync
@filters.command(name="list") @filters.command(name="list")

View file

@ -17,11 +17,18 @@ from pbi_cli.main import PbiContext, pass_context
default=None, default=None,
help="Path to .Report folder (auto-detected from CWD if omitted).", help="Path to .Report folder (auto-detected from CWD if omitted).",
) )
@click.option(
"--no-sync",
is_flag=True,
default=False,
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
)
@click.pass_context @click.pass_context
def report(ctx: click.Context, path: str | None) -> None: def report(ctx: click.Context, path: str | None, no_sync: bool) -> None:
"""Manage Power BI PBIR reports (pages, themes, validation).""" """Manage Power BI PBIR reports (pages, themes, validation)."""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["report_path"] = path ctx.obj["report_path"] = path
ctx.obj["no_sync"] = no_sync
@report.command() @report.command()
@ -195,9 +202,19 @@ def diff_theme(ctx: PbiContext, click_ctx: click.Context, file: str) -> None:
@report.command(name="set-background") @report.command(name="set-background")
@click.argument("page_name") @click.argument("page_name")
@click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.") @click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.")
@click.option(
"--transparency",
"-t",
default=0,
show_default=True,
type=click.IntRange(0, 100),
help="Transparency 0 (opaque) to 100 (invisible). Defaults to 0 so the color is visible.",
)
@click.pass_context @click.pass_context
@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, transparency: int
) -> None:
"""Set the background color of a page.""" """Set the background color of a page."""
from pbi_cli.core.pbir_path import resolve_report_path from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.report_backend import page_set_background from pbi_cli.core.report_backend import page_set_background
@ -210,6 +227,7 @@ def set_background(ctx: PbiContext, click_ctx: click.Context, page_name: str, co
definition_path=definition_path, definition_path=definition_path,
page_name=page_name, page_name=page_name,
color=color, color=color,
transparency=transparency,
) )

View file

@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context
default=None, default=None,
help="Path to .Report folder (auto-detected from CWD if omitted).", help="Path to .Report folder (auto-detected from CWD if omitted).",
) )
@click.option(
"--no-sync",
is_flag=True,
default=False,
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
)
@click.pass_context @click.pass_context
def visual(ctx: click.Context, path: str | None) -> None: def visual(ctx: click.Context, path: str | None, no_sync: bool) -> None:
"""Manage visuals in PBIR report pages.""" """Manage visuals in PBIR report pages."""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["report_path"] = path ctx.obj["report_path"] = path
ctx.obj["no_sync"] = no_sync
def _get_report_path(click_ctx: click.Context) -> str | None: def _get_report_path(click_ctx: click.Context) -> str | None:

View file

@ -163,11 +163,6 @@ def _validate_report_json(definition_path: Path) -> list[ValidationResult]:
ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'") ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'")
) )
if "layoutOptimization" not in data:
findings.append(
ValidationResult("error", "report.json", "Missing required 'layoutOptimization'")
)
return findings return findings

View file

@ -240,8 +240,6 @@ def report_validate(definition_path: Path) -> dict[str, Any]:
data = _read_json(report_json) data = _read_json(report_json)
if "themeCollection" not in data: if "themeCollection" not in data:
errors.append("report.json missing required 'themeCollection'") errors.append("report.json missing required 'themeCollection'")
if "layoutOptimization" not in data:
errors.append("report.json missing required 'layoutOptimization'")
except json.JSONDecodeError: except json.JSONDecodeError:
pass # Already caught above pass # Already caught above
@ -420,14 +418,21 @@ def page_set_background(
definition_path: Path, definition_path: Path,
page_name: str, page_name: str,
color: str, color: str,
transparency: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Set the background color of a page. """Set the background color of a page.
Updates the ``objects.background`` property in ``page.json``. Updates the ``objects.background`` property in ``page.json``.
The color must be a hex string, e.g. ``'#F8F9FA'``. The color must be a hex string, e.g. ``'#F8F9FA'``.
``transparency`` is 0 (fully opaque) to 100 (fully transparent). Desktop
defaults missing transparency to 100 (invisible), so this function always
writes it explicitly. Pass a value to override.
""" """
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", color): 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'.")
if not 0 <= transparency <= 100:
raise PbiCliError(f"Invalid transparency '{transparency}' -- must be 0-100.")
page_dir = get_page_dir(definition_path, page_name) page_dir = get_page_dir(definition_path, page_name)
page_json_path = page_dir / "page.json" page_json_path = page_dir / "page.json"
@ -437,12 +442,18 @@ def page_set_background(
page_data = _read_json(page_json_path) page_data = _read_json(page_json_path)
background_entry = { background_entry = {
"properties": { "properties": {
"color": {"solid": {"color": {"expr": {"Literal": {"Value": f"'{color}'"}}}}} "color": {"solid": {"color": {"expr": {"Literal": {"Value": f"'{color}'"}}}}},
"transparency": {"expr": {"Literal": {"Value": f"{transparency}D"}}},
} }
} }
objects = {**page_data.get("objects", {}), "background": [background_entry]} objects = {**page_data.get("objects", {}), "background": [background_entry]}
_write_json(page_json_path, {**page_data, "objects": objects}) _write_json(page_json_path, {**page_data, "objects": objects})
return {"status": "updated", "page": page_name, "background_color": color} return {
"status": "updated",
"page": page_name,
"background_color": color,
"transparency": transparency,
}
def page_set_visibility( def page_set_visibility(

View file

@ -206,10 +206,10 @@ def _build_visual_json(
"""Fill placeholders in a template string and return parsed JSON.""" """Fill placeholders in a template string and return parsed JSON."""
filled = ( filled = (
template_str.replace("__VISUAL_NAME__", name) template_str.replace("__VISUAL_NAME__", name)
.replace("__X__", str(x)) .replace("__X__", str(int(x)))
.replace("__Y__", str(y)) .replace("__Y__", str(int(y)))
.replace("__WIDTH__", str(width)) .replace("__WIDTH__", str(int(width)))
.replace("__HEIGHT__", str(height)) .replace("__HEIGHT__", str(int(height)))
.replace("__Z__", str(z)) .replace("__Z__", str(z))
.replace("__TAB_ORDER__", str(tab_order)) .replace("__TAB_ORDER__", str(tab_order))
) )
@ -566,11 +566,6 @@ def visual_bind(
query = visual_config.setdefault("query", {}) query = visual_config.setdefault("query", {})
query_state = query.setdefault("queryState", {}) query_state = query.setdefault("queryState", {})
# Collect existing Commands From/Select to merge (fix: don't overwrite)
from_entities: dict[str, dict[str, Any]] = {}
select_items: list[dict[str, Any]] = []
_collect_existing_commands(query, from_entities, select_items)
role_map = ROLE_ALIASES.get(visual_type, {}) role_map = ROLE_ALIASES.get(visual_type, {})
applied: list[dict[str, str]] = [] applied: list[dict[str, str]] = []
@ -588,14 +583,6 @@ def visual_bind(
# Determine measure vs column: explicit flag, or role-based heuristic # Determine measure vs column: explicit flag, or role-based heuristic
is_measure = force_measure or pbir_role in MEASURE_ROLES is_measure = force_measure or pbir_role in MEASURE_ROLES
# Track source alias for Commands block (use full name to avoid collisions)
source_alias = table.replace(" ", "_").lower() if table else "t"
from_entities[source_alias] = {
"Name": source_alias,
"Entity": table,
"Type": 0,
}
# Build queryState projection (uses Entity directly, matching Desktop) # Build queryState projection (uses Entity directly, matching Desktop)
query_ref = f"{table}.{column}" query_ref = f"{table}.{column}"
if is_measure: if is_measure:
@ -613,38 +600,18 @@ def visual_bind(
} }
} }
projection = { projection: dict[str, Any] = {
"field": field_expr, "field": field_expr,
"queryRef": query_ref, "queryRef": query_ref,
"nativeQueryRef": column, "nativeQueryRef": column,
} }
if not is_measure:
projection["active"] = True
# Add to query state # Add to query state
role_state = query_state.setdefault(pbir_role, {"projections": []}) role_state = query_state.setdefault(pbir_role, {"projections": []})
role_state["projections"].append(projection) role_state["projections"].append(projection)
# Build Commands select item (uses Source alias)
if is_measure:
cmd_field_expr: dict[str, Any] = {
"Measure": {
"Expression": {"SourceRef": {"Source": source_alias}},
"Property": column,
}
}
else:
cmd_field_expr = {
"Column": {
"Expression": {"SourceRef": {"Source": source_alias}},
"Property": column,
}
}
select_items.append(
{
**cmd_field_expr,
"Name": query_ref,
}
)
applied.append( applied.append(
{ {
"role": pbir_role, "role": pbir_role,
@ -653,20 +620,6 @@ def visual_bind(
} }
) )
# 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,
}
}
}
]
data["visual"] = visual_config data["visual"] = visual_config
_write_json(vfile, data) _write_json(vfile, data)
@ -714,21 +667,6 @@ def _summarize_field(field: dict[str, Any]) -> str:
return str(field) return str(field)
def _collect_existing_commands(
query: dict[str, Any],
from_entities: dict[str, dict[str, Any]],
select_items: list[dict[str, Any]],
) -> None:
"""Extract existing From entities and Select items from Commands block."""
for cmd in query.get("Commands", []):
sq = cmd.get("SemanticQueryDataShapeCommand", {}).get("Query", {})
for entity in sq.get("From", []):
name = entity.get("Name", "")
if name:
from_entities[name] = entity
select_items.extend(sq.get("Select", []))
def _next_y_position(definition_path: Path, page_name: str) -> float: def _next_y_position(definition_path: Path, page_name: str) -> float:
"""Calculate the next y position to avoid overlap with existing visuals.""" """Calculate the next y position to avoid overlap with existing visuals."""
visuals_dir = definition_path / "pages" / page_name / "visuals" visuals_dir = definition_path / "pages" / page_name / "visuals"

View file

@ -11,7 +11,6 @@
}, },
"visual": { "visual": {
"visualType": "actionButton", "visualType": "actionButton",
"objects": {},
"visualContainerObjects": {}, "visualContainerObjects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
}, },

View file

@ -18,7 +18,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -18,7 +18,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -18,7 +18,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -22,7 +22,6 @@
"isDefaultSort": true "isDefaultSort": true
} }
}, },
"objects": {},
"visualContainerObjects": {}, "visualContainerObjects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }

View file

@ -18,7 +18,6 @@
"Legend": {"projections": []} "Legend": {"projections": []}
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -18,7 +18,6 @@
"Legend": {"projections": []} "Legend": {"projections": []}
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -11,7 +11,6 @@
}, },
"visual": { "visual": {
"visualType": "image", "visualType": "image",
"objects": {},
"visualContainerObjects": {}, "visualContainerObjects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
}, },

View file

@ -24,7 +24,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -24,7 +24,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -16,7 +16,6 @@
"Values": {"projections": [], "active": true} "Values": {"projections": [], "active": true}
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -18,7 +18,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -11,7 +11,6 @@
}, },
"visual": { "visual": {
"visualType": "pageNavigator", "visualType": "pageNavigator",
"objects": {},
"visualContainerObjects": {}, "visualContainerObjects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
}, },

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -24,7 +24,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -11,7 +11,6 @@
}, },
"visual": { "visual": {
"visualType": "shape", "visualType": "shape",
"objects": {},
"visualContainerObjects": {}, "visualContainerObjects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
}, },

View file

@ -18,7 +18,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -18,7 +18,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -16,7 +16,6 @@
"Values": {"projections": []} "Values": {"projections": []}
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -11,7 +11,6 @@
}, },
"visual": { "visual": {
"visualType": "textbox", "visualType": "textbox",
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -21,7 +21,6 @@
} }
} }
}, },
"objects": {},
"drillFilterOtherVisuals": true "drillFilterOtherVisuals": true
} }
} }

View file

@ -159,14 +159,8 @@ class TestBindMerge:
assert len(query["queryState"]["Category"]["projections"]) == 1 assert len(query["queryState"]["Category"]["projections"]) == 1
assert len(query["queryState"]["Y"]["projections"]) == 1 assert len(query["queryState"]["Y"]["projections"]) == 1
# Commands block should have both From entities # PBIR 2.7.0: Commands is a legacy binary format field - must not be present
cmds = query["Commands"][0]["SemanticQueryDataShapeCommand"]["Query"] assert "Commands" not in query
from_names = {e["Entity"] for e in cmds["From"]}
assert "Date" in from_names
assert "Sales" in from_names
# Commands Select should have both fields
assert len(cmds["Select"]) == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -115,18 +115,6 @@ class TestValidateReportFull:
assert result["valid"] is False assert result["valid"] is False
assert any("themeCollection" in e["message"] for e in result["errors"]) 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"}},
},
)
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: def test_page_missing_required_fields(self, valid_report: Path) -> None:
_write( _write(
valid_report / "pages" / "page1" / "page.json", valid_report / "pages" / "page1" / "page.json",

View file

@ -442,20 +442,6 @@ class TestReportValidate:
assert result["valid"] is False assert result["valid"] is False
assert any("themeCollection" in e for e in result["errors"]) assert any("themeCollection" in e for e in result["errors"])
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": {}},
},
)
result = report_validate(sample_report)
assert result["valid"] is False
assert any("layoutOptimization" in e for e in result["errors"])
def test_report_validate_page_missing_page_json(self, sample_report: Path) -> None: def test_report_validate_page_missing_page_json(self, sample_report: Path) -> None:
"""A page folder without page.json is flagged as invalid.""" """A page folder without page.json is flagged as invalid."""
orphan_page = sample_report / "pages" / "orphan_page" orphan_page = sample_report / "pages" / "orphan_page"
@ -1020,9 +1006,24 @@ def test_page_set_background_writes_color(sample_report: Path) -> None:
result = page_set_background(sample_report, "page1", "#F8F9FA") result = page_set_background(sample_report, "page1", "#F8F9FA")
assert result["status"] == "updated" assert result["status"] == "updated"
assert result["background_color"] == "#F8F9FA" assert result["background_color"] == "#F8F9FA"
assert result["transparency"] == 0
page_data = _read(sample_report / "pages" / "page1" / "page.json") page_data = _read(sample_report / "pages" / "page1" / "page.json")
bg = page_data["objects"]["background"][0]["properties"]["color"] props = page_data["objects"]["background"][0]["properties"]
assert bg["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'" assert props["color"]["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'"
# transparency must always be written so Desktop renders the color as opaque
assert props["transparency"]["expr"]["Literal"]["Value"] == "0D"
def test_page_set_background_custom_transparency(sample_report: Path) -> None:
result = page_set_background(sample_report, "page1", "#0E1117", transparency=50)
assert result["transparency"] == 50
props = _read(sample_report / "pages" / "page1" / "page.json")["objects"]["background"][0]["properties"]
assert props["transparency"]["expr"]["Literal"]["Value"] == "50D"
def test_page_set_background_rejects_invalid_transparency(sample_report: Path) -> None:
with pytest.raises(PbiCliError, match="Invalid transparency"):
page_set_background(sample_report, "page1", "#000000", transparency=101)
def test_page_set_background_preserves_other_objects(sample_report: Path) -> None: def test_page_set_background_preserves_other_objects(sample_report: Path) -> None:
@ -1110,6 +1111,7 @@ def test_page_set_background_accepts_valid_color(sample_report: Path) -> None:
result = page_set_background(sample_report, "page1", "#F8F9FA") result = page_set_background(sample_report, "page1", "#F8F9FA")
assert result["status"] == "updated" assert result["status"] == "updated"
assert result["background_color"] == "#F8F9FA" assert result["background_color"] == "#F8F9FA"
assert result["transparency"] == 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -194,7 +194,7 @@ def test_visual_add_matrix(report_with_page: Path) -> None:
def test_visual_add_custom_position(report_with_page: Path) -> None: def test_visual_add_custom_position(report_with_page: Path) -> None:
"""Explicitly provided x, y, width, height are stored verbatim.""" """Explicitly provided x, y, width, height are stored as integers, not floats."""
result = visual_add( result = visual_add(
report_with_page, report_with_page,
"test_page", "test_page",
@ -206,18 +206,35 @@ def test_visual_add_custom_position(report_with_page: Path) -> None:
height=450.0, height=450.0,
) )
assert result["x"] == 100.0 assert result["x"] == 100
assert result["y"] == 200.0 assert result["y"] == 200
assert result["width"] == 600.0 assert result["width"] == 600
assert result["height"] == 450.0 assert result["height"] == 450
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")) data = json.loads(vfile.read_text(encoding="utf-8"))
pos = data["position"] pos = data["position"]
assert pos["x"] == 100.0 assert pos["x"] == 100
assert pos["y"] == 200.0 assert pos["y"] == 200
assert pos["width"] == 600.0 assert pos["width"] == 600
assert pos["height"] == 450.0 assert pos["height"] == 450
# Positions must be integers, not floats (Desktop normalises to int)
assert isinstance(pos["x"], int)
assert isinstance(pos["y"], int)
# ---------------------------------------------------------------------------
# 7b. visual_add - no empty objects key
# ---------------------------------------------------------------------------
def test_visual_add_no_empty_objects(report_with_page: Path) -> None:
"""Scaffolded visuals must not contain an empty 'objects' key."""
visual_add(report_with_page, "test_page", "bar_chart", name="clean_bar")
vfile = report_with_page / "pages" / "test_page" / "visuals" / "clean_bar" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
assert "objects" not in data.get("visual", {}), \
"visual.json must not contain empty 'objects: {}' -- Desktop strips it and schema validators reject it"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -357,19 +374,19 @@ def test_visual_update_position(report_with_page: Path) -> None:
assert result["status"] == "updated" assert result["status"] == "updated"
assert result["name"] == "movable" assert result["name"] == "movable"
assert result["position"]["x"] == 50.0 assert result["position"]["x"] == 50
assert result["position"]["y"] == 75.0 assert result["position"]["y"] == 75
assert result["position"]["width"] == 350.0 assert result["position"]["width"] == 350
assert result["position"]["height"] == 250.0 assert result["position"]["height"] == 250
# Confirm the file on disk reflects the change # 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")) data = json.loads(vfile.read_text(encoding="utf-8"))
pos = data["position"] pos = data["position"]
assert pos["x"] == 50.0 assert pos["x"] == 50
assert pos["y"] == 75.0 assert pos["y"] == 75
assert pos["width"] == 350.0 assert pos["width"] == 350
assert pos["height"] == 250.0 assert pos["height"] == 250
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -469,8 +486,15 @@ def test_visual_bind_category_value(report_with_page: Path) -> None:
assert cat_proj["queryRef"] == "Date.Year" assert cat_proj["queryRef"] == "Date.Year"
assert cat_proj["nativeQueryRef"] == "Year" assert cat_proj["nativeQueryRef"] == "Year"
# The semantic query Commands block should be present # PBIR 2.7.0: Commands is a legacy binary format field - must not be present
assert "Commands" in data["visual"]["query"] assert "Commands" not in data["visual"]["query"]
# Column (category) projections must have active: true so Desktop renders the axis
assert cat_proj.get("active") is True
# Measure (value) projections must NOT have active: true
val_proj = query_state["Y"]["projections"][0]
assert "active" not in val_proj
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------