diff --git a/CHANGELOG.md b/CHANGELOG.md index a9287f7..1c5b727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,83 @@ called out explicitly under **Changed** or **Removed**. Generating a template from a spec file is still not wired; see `kizen docs show email-templates`. +- **`kizen messages templates create --spec-file ` builds a complete email + template — `craft_json` and the compiled, Outlook-safe `content` HTML — + from one declarative spec.** `update --spec-file ` rewrites an + existing template the same way, as an alternative to its existing + field-level `--craft-json-file`/`--content-file` PATCH path. Both fields + come from one pass over one node tree, so a spec can never describe one + without the other — no flag and no spec key accepts a raw `craft_json` or + `content` value. + + A spec's rows pick one of 4 column layouts by name (`1 Column`, `2 + Columns`, `2 Columns (1/3 and 2/3)`, `2 Columns (2/3 and 1/3)`) and cells + hold `text`/`image`/`button`/`divider` blocks — both closed sets, so an + unsupported layout or block kind is a clear error, never a silent partial + template. An `image` block names a local PNG/JPEG file; it's uploaded + (`source="public_image"`, publicly readable so recipients can load it) and + its real pixel dimensions are read from the file's own header bytes — no + new dependency. `--dry-run` resolves images offline instead of uploading, + so it never writes. `messages templates craft-config` previews the + `{craft_json, content}` pair offline, with `--out-html` to drop the + compiled body somewhere a browser (or Outlook) can open it. + + A real test send opened in Outlook is still the only way to confirm actual + rendering — nothing offline can substitute for that. + +- **Email template specs can now set the layout knobs a designed newsletter + needs — `Section`/`Row` width and padding, `Divider` thickness, `Button` + corner radius/padding/alignment — instead of every template landing at + this emitter's fixed defaults.** `sections[].max_width`/`container_width`/ + `padding` and `sections[].rows[].width`/`container_width`/`padding` set + `Section`/`Row` props directly; `padding` is four independent + `{top, right, bottom, left}` strings, matching the wire format's four + independent `containerPadding*` keys rather than a lossy CSS-style + shorthand. `button` blocks gain `border_radius`/`padding_left`/ + `padding_right`/`alignment`; `divider` blocks gain `size`. Every new field + defaults to this emitter's exact pre-existing hardcoded value, so a spec + that sets none of them produces the same output as before this change. + The compiled `content` HTML's row widths now track these same values too + (previously frozen at a hardcoded 880px regardless of what the spec set — + a real `craft_json`/`content` divergence, the exact failure this whole + surface exists to prevent), `content` now carries `Section`/`Row` + padding at all (previously absent entirely, on every template — text + always rendered flush against the canvas edge regardless of what + `craft_json` said), `content`'s `Button` markup now carries `align` + (previously every button rendered left-aligned regardless of the spec's + `alignment`), and a centered `Image` (`position: "center"`, the only + value this surface sets) now actually renders centered in `content` + instead of flush left. + ### Fixed +- **Compiled email `content` no longer diverges from what Kizen's own + builder produces for the same layout.** Every recipient's email now + carries a real `font-family` for body text (`Root.props.fontFamily`, + via the same `kizen-text-styles` wrapper class/`" +) + +# `.kizen-text-styles` — the class Kizen's own compiler uses to scope text +# typography and rich-text element styling (links, paragraphs, code/pre). +# The rule text itself carries no per-template data except `linkColor` +# (interpolated below), so it's kept as one structural block, confirmed +# against the reference template's compiled `content`. BCLI-023's text model +# (paragraph/list/code rendering) is untouched by this — these rules only +# apply Kizen's own styling to whatever HTML a `Text` block already embeds. +_KIZEN_TEXT_STYLES_TEMPLATE = ( + ".kizen-text-styles a {{ color: {link_color}; text-decoration: none; }}" + ".kizen-text-styles a *, .kizen-text-styles span * " + "{{ color: inherit; font-size: inherit; }}" + ".kizen-text-styles a:hover, .kizen-text-styles a:focus, " + ".kizen-text-styles a:hover *, .kizen-text-styles a:focus * " + "{{ text-decoration: underline; }}" + ".kizen-text-styles a:hover s, .kizen-text-styles a:focus s " + "{{ text-decoration: underline line-through; }}" + ".kizen-text-styles p {{ margin: 0; line-height: 1.5em; min-height: 1em; }}" + ".kizen-text-styles p * " + "{{ font-family: inherit; font-size: inherit; line-height: inherit; }}" + ".kizen-text-styles ul, .kizen-text-styles ol " + "{{ margin-top: 0; margin-bottom: 10px; }}" + ".kizen-text-styles code {{ font-family: 'Courier New', Monospace; " + "font-size: inherit; font-weight: 400; background-color: #F5F6F7; " + "padding: 5px; color: #4A5660; border-radius: 4px; }}" + ".kizen-text-styles pre {{ padding: 10px; background-color: #F5F6F7; " + "border-radius: 4px; border: 1px solid #D8DDE1; }}" + ".kizen-text-styles pre code {{ padding: 0; background-color: unset; " + "border-radius: 0; white-space: pre-wrap; word-break: break-all; }}" +) + + +def _section_class(node_id: str) -> str: + """`section-` — the existing Section/Row coupling class every + `_render_section`/`_render_row` call site uses. A tiny shared helper so + it and `_image_auto_class` below format node ids identically rather than + as two independently hand-rolled f-strings — see + `test_image_and_section_class_conventions_share_id_formatting`.""" + return f"section-{node_id}" + + +def _image_auto_class(node_id: str) -> str: + """`image--auto` — the auto-mode image sizing class, same + `-[-suffix]` shape as `_section_class` above. Confirmed + against the reference template's compiled `content` + (`.image--auto > table td { ... }`, one real occurrence + inspected structurally, node id not reproduced here).""" + return f"image-{node_id}-auto" + + +class ColumnLayout: + __slots__ = ("preset", "columns", "classes", "media_widths") + + def __init__( + self, + preset: str, + columns: tuple[float, ...], + classes: tuple[str, ...], + media_widths: tuple[str, ...], + ) -> None: + self.preset = preset + self.columns = columns + self.classes = classes + self.media_widths = media_widths + + +# 880px was the content width in every case observed pre-BCLI-024 (900 +# Section maxWidth - 20px padding, both hardcoded at the time). Now that +# `Section.max_width`/`Row.container_width`/padding are spec-settable +# (BCLI-024), the actual per-row pixel width is computed by +# `_row_content_width_px` below, not held as one constant — see that +# function's docstring for the fallback formula, which reduces to exactly +# 880.0 when nothing is overridden. + +COLUMN_LAYOUTS: dict[str, ColumnLayout] = { + "1 Column": ColumnLayout( + "1 Column", + (1,), + ("mj-column-per-100",), + ("100%",), + ), + "2 Columns": ColumnLayout( + "2 Columns", + (0.5, 0.5), + ("mj-column-per-50", "mj-column-per-50"), + ("50%", "50%"), + ), + "2 Columns (1/3 and 2/3)": ColumnLayout( + "2 Columns (1/3 and 2/3)", + (0.3333333333333333, 0.6666666666666666), + ("mj-column-per-33-333332", "mj-column-per-66-666664"), + ("33.333332%", "66.666664%"), + ), + "2 Columns (2/3 and 1/3)": ColumnLayout( + "2 Columns (2/3 and 1/3)", + (0.6666666666666666, 0.3333333333333333), + ("mj-column-per-66-666664", "mj-column-per-33-333332"), + ("66.666664%", "33.333332%"), + ), +} + + +def _resolved_name(node: dict[str, Any]) -> str: + t = node.get("type") + return str(t.get("resolvedName")) if isinstance(t, dict) else str(t) + + +def _layout_for_columns(columns: list[float]) -> ColumnLayout: + for layout in COLUMN_LAYOUTS.values(): + if list(layout.columns) == list(columns): + return layout + raise ValueError(f"no v1 column layout matches columns={columns!r}") + + +def _render_button(node: dict[str, Any]) -> str: + p = node["props"] + return ( + '' + "
' + f'' + f"{escape(p['label'])}
" + ) + + +def _render_divider(node: dict[str, Any]) -> str: + p = node["props"] + return ( + f'

' + ) + + +def _ancestor_section_props(node_id: str, craft_json: dict[str, Any]) -> dict[str, Any]: + """Walk a leaf block's fixed 3-hop ancestry — block -> Cell -> Row -> + Section (`_assemble_cell` always sets a block's own `parent` to the + enclosing Cell's id, per `tools.form_ui`) — and return the enclosing + `Section`'s own `props`. Used by auto-mode image sizing below to read + the Section's `containerWidth`.""" + cell_id = craft_json[node_id]["parent"] + row_id = craft_json[cell_id]["parent"] + section_id = craft_json[row_id]["parent"] + return craft_json[section_id]["props"] + + +def _render_image(node_id: str, craft_json: dict[str, Any]) -> tuple[str, list[str]]: + """Return `(html, extra_style_rules)`. Kizen wraps every Image block in + the same two-level table Kizen's own compiler uses (confirmed against + the reference's compiled `content` for both a fixed-width and an + auto-mode image) — an outer `` carrying block padding and, in auto + mode, the `.image--auto` coupling class, then a nested + `' in content + assert " { max-width:880px; }" in content + assert 'width:880px;">' in content # the 1-Column row's own mso
` around the `` itself. That inner + `` is what the auto-mode CSS rule's `> table td` selector actually + targets — the rule is meaningless without this wrapper, so the two are + implemented together, not the rule alone. + + Auto mode (`Image.props.width` absent — see `_assemble_email_block`): + the ``'s `width` attribute becomes the parent Section's own + `containerWidth`, falling back to `Root.props.maxWidth` when the + Section doesn't set one (inferred, not observed live — see the work + item's Open questions), and a `.image--auto > table td` rule + caps it at the image's own `naturalWidth`. + """ + node = craft_json[node_id] + p = node["props"] + style_rules: list[str] = [] + explicit_width = p.get("width") + if explicit_width is not None: + img_width: Any = explicit_width + td_class = "" + else: + section_props = _ancestor_section_props(node_id, craft_json) + container_width = section_props.get("containerWidth") + if container_width is None: + container_width = craft_json["ROOT"]["props"]["maxWidth"] + img_width = container_width + td_class = _image_auto_class(node_id) + natural_width = p.get("naturalWidth") + if natural_width is not None: + style_rules.append( + f".{td_class} > table td {{ width: 100% !important; " + f"max-width: {natural_width}px; }}" + ) + + img = ( + f'{escape(p.get(' + ) + wrapped = ( + '' + f'
' + '' + f'' + "
{img}
" + "
" + ) + link = p.get("link") + if link: + wrapped = f'{wrapped}' + return wrapped, style_rules + + +def _render_text(node: dict[str, Any], craft_json: dict[str, Any]) -> str: + """The wrapper Kizen's own compiler puts around every Text block's copy + — the `kizen-text-styles` class plus its typography, sourced from + `Root.props` (confirmed against the reference's compiled `content`): + `font-family`/`font-size`/`color` (rgba->hex converted) come from + `Root.props`; `line-height:1`/`text-align:left` are fixed literals with + no controlling Root prop in the reference. `custom.text` is still + embedded verbatim inside it — this never touches BCLI-023's text model, + only the wrapper *around* it.""" + root_props = craft_json["ROOT"]["props"] + font_family = root_props.get("fontFamily", "Arial") + font_size = root_props.get("fontSize", "14") + color = _rgba_to_hex(root_props.get("color", "rgba(74,86,96,1)")) + return ( + '
{node["custom"]["text"]}
' + ) + + +def _render_block(node_id: str, craft_json: dict[str, Any]) -> tuple[str, list[str]]: + node = craft_json[node_id] + name = _resolved_name(node) + if name == "Text": + # Embedded verbatim, not stripped — see craft_summary()'s _plain_text, + # which tag-strips both sides before comparing. + return _render_text(node, craft_json), [] + if name == "Image": + return _render_image(node_id, craft_json) + if name == "Button": + return _render_button(node), [] + if name == "Divider": + return _render_divider(node), [] + raise ValueError(f"cannot compile HTML for unsupported node type {name!r}") + + +def _render_cell(cell_id: str, craft_json: dict[str, Any]) -> tuple[str, list[str]]: + node = craft_json[cell_id] + html_parts: list[str] = [] + style_rules: list[str] = [] + for block_id in node["nodes"]: + html, rules = _render_block(block_id, craft_json) + html_parts.append(html) + style_rules.extend(rules) + return "".join(html_parts), style_rules + + +def _truncate4(value: float) -> float: + """Truncate (not round) to 4 decimal places — matches the live-confirmed + column-width convention (`293.3333`, not `293.3333333333333` or the + rounded `293.3334`; `586.6666`, not the rounded `586.6667`).""" + return math.floor(value * 10000) / 10000 + + +def _row_content_width_px(row_id: str, craft_json: dict[str, Any]) -> float: + """The row's actual compiled pixel width — the value the mso table and + the `.section- { max-width:...px; }` rule must use. + + Trusts an explicit `Row.props.containerWidth` when the spec set one + directly (BCLI-024's independent, spec-settable field — the same + "explicit, not derived" trust model this surface already uses for + `Row.props.columns`/`Cell.props.__width`). Otherwise derives it from the + parent `Section`'s own `maxWidth` and padding: content width = maxWidth + - (paddingLeft + paddingRight) — the formula this module's `content + width` comment already stated, now actually applied instead of frozen + as one hardcoded constant. With every prop at its pre-BCLI-024 default + (`Section.maxWidth: "900"`, padding `"10"` each side), this reduces to + exactly `900 - 10 - 10 = 880.0`, the old hardcoded value — so unmodified + specs compile identically to before. + + Either way, the result is then scaled by `Row.props.width` (percent, + default `"100"`) — Kizen's real compiler applies this as a genuine + multiplier on top of `containerWidth`/the derived width, confirmed + against the reference template (`width: '75'` on a `containerWidth: + '580'` row compiles to `435px`, not `580px`). A default `"100"` makes + this a no-op. + """ + row_props = craft_json[row_id]["props"] + if "containerWidth" in row_props: + base_width = float(row_props["containerWidth"]) + else: + section_props = craft_json[craft_json[row_id]["parent"]]["props"] + max_width = float(section_props["maxWidth"]) + pad_left = float(section_props.get("containerPaddingLeft", 0)) + pad_right = float(section_props.get("containerPaddingRight", 0)) + base_width = max_width - pad_left - pad_right + return base_width * float(row_props.get("width", 100)) / 100 + + +def _padding_css(props: dict[str, Any]) -> str: + """CSS `padding` shorthand (top/right/bottom/left, no unit suffix on the + value) from a node's own `containerPadding{Top,Right,Bottom,Left}` + props — the same order `_render_button` already uses for its own + padding. Reads whatever `Section`/`Row` actually carries in + `craft_json`, default `"10"` or an explicit spec override alike, so + `content` cannot silently disagree with `craft_json` the way it did + before this fix (Section/Row padding never reached the compiled output + at all).""" + return ( + f"{props.get('containerPaddingTop', '0')}px " + f"{props.get('containerPaddingRight', '0')}px " + f"{props.get('containerPaddingBottom', '0')}px " + f"{props.get('containerPaddingLeft', '0')}px" + ) + + +def _fmt_px(value: float) -> str: + """Format a computed pixel length for the compiled CSS without a + spurious trailing `.0` (`880.0px` -> `880px`) while leaving a genuine + fractional value untouched (`293.3333px` stays `293.3333px`). Used at + `_render_row`'s width call sites: `_row_content_width_px`'s two sites + and the per-column `mso_widths_px` (`_truncate4`'s output).""" + if value == int(value): + return str(int(value)) + return str(value) + + +def _render_row(row_id: str, craft_json: dict[str, Any]) -> tuple[str, list[str]]: + """Return (body_html, style_rules) for one Row — `style_rules` is the + row's own `max-width` rule followed by any rules its cells' blocks + contributed (currently only an auto-mode Image's `.image--auto` + rule, see `_render_image`).""" + node = craft_json[row_id] + columns = node["props"]["columns"] + layout = _layout_for_columns(columns) + cell_ids = [node["linkedNodes"][f"column-{i + 1}"] for i in range(len(columns))] + content_width_px = _row_content_width_px(row_id, craft_json) + content_width_str = _fmt_px(content_width_px) + mso_widths_px = [ + _fmt_px(_truncate4(content_width_px * frac)) for frac in layout.columns + ] + + parts = [ + f'
' + ] + parts.append( + '' + ) + else: + parts.append( + f'" + ) + parts.append( + f'
' + ) + cell_html, cell_style_rules = _render_cell(cid, craft_json) + parts.append(cell_html) + extra_style_rules.extend(cell_style_rules) + parts.append("
") + parts.append("") + parts.append("
") + + style_rule = f".{_section_class(row_id)} {{ max-width:{content_width_str}px; }}" + return "".join(parts), [style_rule, *extra_style_rules] + + +def _render_section( + section_id: str, craft_json: dict[str, Any] +) -> tuple[str, list[str]]: + node = craft_json[section_id] + props = node["props"] + bg = props.get("containerBackgroundColor", "#FFFFFF") + rows_html: list[str] = [] + style_rules = [f".{_section_class(section_id)} {{ background-color:{bg}; }}"] + for row_id in node["nodes"]: + row_html, row_rules = _render_row(row_id, craft_json) + rows_html.append(row_html) + style_rules.extend(row_rules) + section_padding = _padding_css(props) + body = ( + f'
' + + "".join(rows_html) + + "
" + ) + + # The outer background-table wrapper `Section.props.containerWidth` + # needs, deferred here from BCLI-024 (see that item's Outcome). Only + # added when the spec set an explicit container_width — matching this + # surface's existing "None means no override, byte-identical to + # pre-this-prop output" convention (see `_section_props`), and the only + # case this module has real reference evidence for. Simplified relative + # to Kizen's own VML/background-image fallback markup (this emitter has + # no background-image concept at all, only `background_color`) — see the + # work item's report for the scoping call. + container_width = props.get("containerWidth") + if container_width is not None: + style_rules.append( + f".{_section_class(section_id)} {{ max-width:{container_width}px; }}" + ) + body = ( + '" + body + "" + ) + return body, style_rules + + +def _distinct_column_widths(craft_json: dict[str, Any]) -> dict[str, str]: + """Every distinct `mj-column-per-N` class in use across this template's + `Row` nodes, mapped to its media-query width percentage. Shared by + `_column_base_width_rules`, `_media_query_rules`, and + `_moz_text_html_style_block` so the three rule sets can't independently + drift on which classes exist.""" + seen: dict[str, str] = {} + for node in craft_json.values(): + if _resolved_name(node) != "Row": + continue + columns = node["props"]["columns"] + layout = _layout_for_columns(columns) + for cls, media_w in zip(layout.classes, layout.media_widths, strict=True): + seen[cls] = media_w + return seen + + +def _column_base_width_rules(craft_json: dict[str, Any]) -> list[str]: + """Unconditional `.mj-column-per-N` width rules — MJML's own convention. + + Each column `
` also carries a hardcoded inline `width:100%` (see + `_render_row`), which is the fallback for clients that ignore `' + + +def _compile_html(craft_json: dict[str, Any]) -> str: + root = craft_json["ROOT"] + root_props = root["props"] + bodies: list[str] = [] + style_rules: list[str] = [] + for section_id in root["nodes"]: + body, rules = _render_section(section_id, craft_json) + bodies.append(body) + style_rules.extend(rules) + + # Confirmed live 2026-08-26: `Root.props.mobileBreak` (`"414"` by + # default), not the hardcoded `480` this module used before. The `480` + # fallback below only applies if a caller's `craft_json` predates this + # prop entirely — every tree this module itself builds always has it. + mobile_break = str(root_props.get("mobileBreak", "480")) + column_rules = _column_base_width_rules(craft_json) + media_rules = _media_query_rules(craft_json) + style_block = ( + '" + + _moz_text_html_style_block(craft_json, mobile_break) + ) + link_color = _rgba_to_hex(root_props.get("linkColor", "rgba(82,142,249,1)")) + kizen_text_styles_block = ( + "" + ) + body_bg = root_props.get("backgroundColor", "#F8FAFF") + return ( + "" + '' + "" + '' + '' + '' + + _MJML_RESET_STYLE + + "" + + style_block + + kizen_text_styles_block + + "" + f'' + f'
' + + "".join(bodies) + + "
" + ) diff --git a/src/kizen_builder/tools/email_images.py b/src/kizen_builder/tools/email_images.py new file mode 100644 index 0000000..a4db24f --- /dev/null +++ b/src/kizen_builder/tools/email_images.py @@ -0,0 +1,126 @@ +"""Image upload + header-byte pixel dimensions for email `Image` blocks. + +Split out of `tools/email_craft.py` (see that module's docstring for the +craft_json/content coupling invariant this surface exists around) — nothing +here mints a node id or touches `craft_json`/`content`. `email_craft.py` +imports `upload_email_image`/`read_image_dimensions` by name so its own +`resolve_spec_images`/`offline_resolve_spec_images` keep calling them as if +they were still local. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from kizen_builder.api import files as files_api +from kizen_builder.api.client import KizenClient + + +def _png_dimensions(data: bytes) -> tuple[int, int]: + # Signature (8 bytes) + IHDR chunk: length(4) type(4) width(4) height(4). + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a valid PNG (bad signature)") + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + return width, height + + +# JPEG SOF (start-of-frame) markers that carry dimensions. Excludes DHT +# (0xC4), JPG (0xC8), DAC (0xCC) — same-range bytes that are NOT SOF markers. +_JPEG_SOF_MARKERS = frozenset( + {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} +) +# Markers with no following length/payload — skip straight past them. +_JPEG_STANDALONE_MARKERS = frozenset({0x01, 0xD8, 0xD9} | set(range(0xD0, 0xD8))) + + +def _jpeg_dimensions(data: bytes) -> tuple[int, int]: + if len(data) < 4 or data[0:2] != b"\xff\xd8": + raise ValueError("not a valid JPEG (bad SOI marker)") + pos = 2 + n = len(data) + while pos < n - 1: + if data[pos] != 0xFF: + raise ValueError("malformed JPEG: expected a marker") + marker = data[pos + 1] + pos += 2 + while marker == 0xFF and pos < n: # padding fill bytes between markers + marker = data[pos] + pos += 1 + if marker in _JPEG_STANDALONE_MARKERS: + continue + if pos + 2 > n: + break + seg_len = int.from_bytes(data[pos : pos + 2], "big") + if marker in _JPEG_SOF_MARKERS: + if pos + 7 > n: + break + height = int.from_bytes(data[pos + 3 : pos + 5], "big") + width = int.from_bytes(data[pos + 5 : pos + 7], "big") + return width, height + pos += seg_len + raise ValueError("no SOF0/SOF2 segment found in JPEG") + + +def read_image_dimensions(data: bytes) -> tuple[int, int, str]: + """Return ``(width, height, content_type)`` read from the file's own + header bytes. PNG and JPEG only — both are real cases on this surface + (every image already stored in the target environment is PNG, but the + browser trace that settled the ``source`` question was a JPEG upload). + GIF/WebP/SVG fail loudly as unsupported rather than being silently + mis-parsed; SVG especially has no pixel dimensions to read this way at + all. + """ + if data[:8] == b"\x89PNG\r\n\x1a\n": + w, h = _png_dimensions(data) + return w, h, "image/png" + if data[:3] == b"\xff\xd8\xff": + w, h = _jpeg_dimensions(data) + return w, h, "image/jpeg" + if data[:6] in (b"GIF87a", b"GIF89a"): + raise ValueError("GIF is not supported on this surface — PNG or JPEG only") + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + raise ValueError("WebP is not supported on this surface — PNG or JPEG only") + if data[:5] == b" dict[str, Any]: + """Upload a local PNG/JPEG for use in an Image block and return the + resolved block fields (``file_id``, ``src``, ``name``, ``natural_width``, + ``natural_height``). + + A real write — reuses ``api.files.upload_file`` with + ``source=files_api.PUBLIC_IMAGE`` and ``is_public=True``, confirmed live + 2026-08-25 (without ``is_public``, the upload defaults to non-public and + the resulting `src` 404s for any recipient without an authenticated + session — see `docs/specs/email-templates.md`). Callers outside + ``tools/planners/`` only (planners never write — see ``CLAUDE.md``); the + CLI only calls this for a real apply — under ``--dry-run`` it calls + :func:`email_craft.offline_resolve_spec_images` instead, which uploads + nothing. ``base_url`` is the target env's own base URL + (``EnvConfig.base_url``) — ``Image.src`` is host-absolute, confirmed + live, so a template is environment-bound. + """ + src_path = Path(path) + data = src_path.read_bytes() + width, height, _content_type = read_image_dimensions(data) + registered = files_api.upload_file( + client, src_path, source=files_api.PUBLIC_IMAGE, is_public=True + ) + file_id = registered["id"] + src = f"{base_url}/api/files/{file_id}/download" + return { + "file_id": file_id, + "src": src, + "name": src_path.name, + "natural_width": width, + "natural_height": height, + } diff --git a/src/kizen_builder/tools/form_ui.py b/src/kizen_builder/tools/form_ui.py index 5e415f4..68df1ed 100644 --- a/src/kizen_builder/tools/form_ui.py +++ b/src/kizen_builder/tools/form_ui.py @@ -76,6 +76,7 @@ import json import uuid +from collections.abc import Callable from typing import Any # --------------------------------------------------------------------------- @@ -496,14 +497,27 @@ def _assemble_block( def _assemble_cell( - cell_spec: dict[str, Any], parent_id: str, content: dict[str, Any] + cell_spec: dict[str, Any], + parent_id: str, + content: dict[str, Any], + *, + width: float | None = None, + cell_props: Callable[[float | None], dict[str, Any]] | None = None, + block_assembler: Callable[[dict[str, Any], str, dict[str, Any]], str] | None = None, ) -> str: + """``cell_props``/``block_assembler`` are additive hooks: both default to + today's behaviour (``props: {}``, this module's own block dispatch), so + forms/layouts callers that don't pass them are unaffected. A surface with + a different ``Cell.props`` shape (email's ``{"__width": }`` — + see ``tools.email_craft``) or different leaf-block prop shapes passes its + own callables instead of forking this function.""" + assemble = block_assembler or _assemble_block cell_id = _new_id() - block_ids = [_assemble_block(b, cell_id, content) for b in cell_spec["blocks"]] + block_ids = [assemble(b, cell_id, content) for b in cell_spec["blocks"]] content[cell_id] = { "type": {"resolvedName": "Cell"}, "isCanvas": True, - "props": {}, + "props": cell_props(width) if cell_props else {}, "displayName": "Cell", "custom": {}, "parent": parent_id, @@ -515,23 +529,42 @@ def _assemble_cell( def _assemble_row( - row_spec: dict[str, Any], parent_id: str, content: dict[str, Any] + row_spec: dict[str, Any], + parent_id: str, + content: dict[str, Any], + *, + cell_props: Callable[[float | None], dict[str, Any]] | None = None, + block_assembler: Callable[[dict[str, Any], str, dict[str, Any]], str] | None = None, + row_props: Callable[[dict[str, Any]], dict[str, Any]] | None = None, ) -> str: row_id = _new_id() cells = row_spec["cells"] n = len(cells) or 1 columns = row_spec.get("columns") or [1.0 / n] * n - cell_ids = [_assemble_cell(c, row_id, content) for c in cells] + cell_ids = [ + _assemble_cell( + c, + row_id, + content, + width=columns[i] if i < len(columns) else None, + cell_props=cell_props, + block_assembler=block_assembler, + ) + for i, c in enumerate(cells) + ] + props = { + "columns": columns, + **_CONTAINER_DEFAULTS, + "maxWidth": "900", + "width": "100", + "alignment": "center", + } + if row_props: + props.update(row_props(row_spec)) content[row_id] = { "type": {"resolvedName": "Row"}, "isCanvas": False, - "props": { - "columns": columns, - **_CONTAINER_DEFAULTS, - "maxWidth": "900", - "width": "100", - "alignment": "center", - }, + "props": props, "displayName": "Row", "custom": {}, "parent": parent_id, @@ -543,20 +576,40 @@ def _assemble_row( def _assemble_section( - section_spec: dict[str, Any], parent_id: str, content: dict[str, Any] + section_spec: dict[str, Any], + parent_id: str, + content: dict[str, Any], + *, + cell_props: Callable[[float | None], dict[str, Any]] | None = None, + block_assembler: Callable[[dict[str, Any], str, dict[str, Any]], str] | None = None, + section_props: Callable[[dict[str, Any]], dict[str, Any]] | None = None, + row_props: Callable[[dict[str, Any]], dict[str, Any]] | None = None, ) -> str: section_id = _new_id() - row_ids = [_assemble_row(r, section_id, content) for r in section_spec["rows"]] + row_ids = [ + _assemble_row( + r, + section_id, + content, + cell_props=cell_props, + block_assembler=block_assembler, + row_props=row_props, + ) + for r in section_spec["rows"] + ] + props = { + **_CONTAINER_DEFAULTS, + "containerBackgroundColor": section_spec.get("background_color", "#FFFFFF"), + "maxWidth": "900", + "width": "100", + "alignment": "center", + } + if section_props: + props.update(section_props(section_spec)) content[section_id] = { "type": {"resolvedName": "Section"}, "isCanvas": True, - "props": { - **_CONTAINER_DEFAULTS, - "containerBackgroundColor": section_spec.get("background_color", "#FFFFFF"), - "maxWidth": "900", - "width": "100", - "alignment": "center", - }, + "props": props, "displayName": "Section", "custom": {}, "parent": parent_id, @@ -568,7 +621,13 @@ def _assemble_section( def build_content_tree( - sections: list[dict[str, Any]], *, root_props: dict[str, Any] | None = None + sections: list[dict[str, Any]], + *, + root_props: dict[str, Any] | None = None, + cell_props: Callable[[float | None], dict[str, Any]] | None = None, + block_assembler: Callable[[dict[str, Any], str, dict[str, Any]], str] | None = None, + section_props: Callable[[dict[str, Any]], dict[str, Any]] | None = None, + row_props: Callable[[dict[str, Any]], dict[str, Any]] | None = None, ) -> dict[str, Any]: """Assemble a ``Root`` → ``Section`` → ``Row`` → ``Cell`` → block camelCase craft.js tree from :func:`section`/:func:`row`/:func:`cell`/ @@ -587,9 +646,30 @@ def build_content_tree( ``height``/``maxWidth``/``hasShadow``/``tabletBreak``/``mobileBreak`` (matching the dashboard static-content dashlet's Root shape, just camelCased instead of snake_case). + + ``cell_props``/``block_assembler`` are additive hooks for a surface whose + ``Cell.props`` or leaf-block prop shapes differ from this module's forms + defaults — see ``tools.email_craft``, which needs ``Cell.props`` to carry + ``{"__width": }`` and its own Button/Divider prop construction. + ``section_props``/``row_props`` are the same shape of hook for + ``Section``/``Row`` props — each takes the section/row spec dict and + returns a props-override dict merged over this module's defaults. All + four default to ``None``, which reproduces today's exact output — the + call sites in this file and in ``tools/layouts.py`` are unaffected. """ content: dict[str, Any] = {} - section_ids = [_assemble_section(s, "ROOT", content) for s in sections] + section_ids = [ + _assemble_section( + s, + "ROOT", + content, + cell_props=cell_props, + block_assembler=block_assembler, + section_props=section_props, + row_props=row_props, + ) + for s in sections + ] content["ROOT"] = { "type": {"resolvedName": "Root"}, "isCanvas": True, diff --git a/src/kizen_builder/tools/planners/messages.py b/src/kizen_builder/tools/planners/messages.py index 56dae91..2802077 100644 --- a/src/kizen_builder/tools/planners/messages.py +++ b/src/kizen_builder/tools/planners/messages.py @@ -10,10 +10,26 @@ from kizen_builder.api.client import KizenClient from kizen_builder.config import load_env_config +from kizen_builder.models.spec.email_templates import EmailTemplateDef +from kizen_builder.tools import email_craft from kizen_builder.tools.automations import get_automation from kizen_builder.tools.messages import craft_summary, resolve_template from kizen_builder.tools.plans import Plan, PlanError, PlanOperation +# Only value ever observed live for a created template (see +# docs/specs/email-templates.md). No enum is declared anywhere in the repo +# (BCLI-015 left this field unwired since no create path existed until now), +# so this is hard-coded rather than exposed as an unguessable --sender-type +# flag. +_DEFAULT_SENDER_TYPE = "business" + +# `create_automation_message_from_template` (api/messages.py) already sends +# this for the automation-message resource; `POST /api/messages/templates` +# turns out to require it too — confirmed live 2026-08-25 the hard way (a +# `400 {"from_name_type": ["This field is required."]}` from a create +# missing it, not something the earlier probe's PATCH-only testing surfaced). +_DEFAULT_FROM_NAME_TYPE = "default" + # Copied onto a clone; everything else is server-assigned (id, created, # updated, is_editable) or a back-reference that must not be carried over. _CLONED_FIELDS = ( @@ -116,13 +132,89 @@ def plan_clone_template(source: str, new_name: str) -> Plan: ) -def plan_update_template(template: str, patch: dict[str, Any]) -> Plan: +def plan_create_template_from_spec( + spec: EmailTemplateDef, resolved_sections: list[dict[str, Any]] +) -> Plan: + """Plan creating an email template whose ``craft_json``/``content`` are + built from a spec, not hand-authored. + + ``resolved_sections`` comes from ``tools.email_craft.resolve_spec_images`` + — image blocks are already uploaded by the time this runs (a real write, + but not one this function performs; see that function's docstring for + why it can't happen here — planners never write, ``CLAUDE.md``). Building + the tree and compiling the HTML both happen inside + ``email_craft.build_email_content()``, in one pass over one set of ids, + so the two fields cannot go out of sync. + """ + config = load_env_config() + try: + sections = email_craft.assemble_sections(resolved_sections) + craft_json, content = email_craft.build_email_content(sections) + except ValueError as e: + raise PlanError(str(e)) from e + + payload: dict[str, Any] = { + "name": spec.name, + "subject": spec.subject, + "type": "email", + "sender_type": _DEFAULT_SENDER_TYPE, + "from_name_type": _DEFAULT_FROM_NAME_TYPE, + "craft_json": craft_json, + "content": content, + } + op = PlanOperation( + action="create", + kind="email_template", + key=spec.name, + preview={ + "env": config.name, + "name": spec.name, + "subject": spec.subject, + "sections": len(spec.sections), + "craft_json": f"{len(craft_json)} nodes", + "content": f"{len(content)} chars", + }, + payload=payload, + ) + return Plan.build( + env=config.name, + summary=f"Create email template '{spec.name}' from spec", + operations=[op], + ) + + +def plan_update_template( + template: str, + patch: dict[str, Any] | None = None, + *, + spec: EmailTemplateDef | None = None, + resolved_sections: list[dict[str, Any]] | None = None, +) -> Plan: """Plan a PATCH of one email template's fields. - ``patch`` is applied verbatim, including explicit ``None`` values — - that is deliberate, since clearing ``content`` is the way to ask - whether the server recompiles it from ``craft_json``. + Two mutually exclusive input modes, matching the CLI's two update paths: + + - ``patch`` — the raw field-level PATCH (``--craft-json-file``/ + ``--content-file``/``--name``/``--subject``), applied verbatim + including explicit ``None`` values (clearing ``content`` is the way to + ask whether the server recompiles it from ``craft_json`` — it doesn't). + - ``spec``/``resolved_sections`` — rebuilds both content fields from a + spec file the same way ``create`` does (``--spec-file``); overrides + ``name``/``subject`` too if the spec sets them. """ + if spec is not None: + try: + sections = email_craft.assemble_sections(resolved_sections or []) + craft_json, content = email_craft.build_email_content(sections) + except ValueError as e: + raise PlanError(str(e)) from e + patch = { + "name": spec.name, + "subject": spec.subject, + "craft_json": craft_json, + "content": content, + } + config, tmpl = _resolve(template) if not patch: raise PlanError("nothing to update — pass at least one field") diff --git a/tests/drift/test_email_template_roundtrip.py b/tests/drift/test_email_template_roundtrip.py new file mode 100644 index 0000000..09be8b4 --- /dev/null +++ b/tests/drift/test_email_template_roundtrip.py @@ -0,0 +1,175 @@ +"""Drift test: `messages templates create --spec-file`'s payload round-trips +against a real environment — the same code path the CLI uses end to end: +spec -> `email_craft.resolve_spec_images()` (a real image upload, +`source="public_image"`) -> `plan_create_template_from_spec()` -> `apply_plan()`. + +Everything created is registered with `scratch` immediately after it's +created, per the module-level convention in `tests/drift/conftest.py`. +""" + +from __future__ import annotations + +import struct +import zlib + +import httpx +import pytest + +from kizen_builder.api import files as files_api +from kizen_builder.api import messages as messages_api +from kizen_builder.models.spec.email_templates import EmailTemplateDef +from kizen_builder.tools import email_craft as ec +from kizen_builder.tools.messages import craft_summary +from kizen_builder.tools.planners import messages as message_planners +from kizen_builder.tools.plans import apply_plan +from tests.drift.conftest import debris_name + +pytestmark = pytest.mark.drift + + +def _make_png(width: int, height: int) -> bytes: + sig = b"\x89PNG\r\n\x1a\n" + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + raw = (b"\x00" + b"\xff\x00\x00" * width) * height + idat = zlib.compress(raw) + return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + + +def test_create_template_from_spec_roundtrips_live( + drift_client, drift_config, scratch, tmp_path +): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_make_png(9, 7)) + + spec = EmailTemplateDef.model_validate( + { + "name": debris_name("email-template"), + "subject": "Drift check — safe to delete", + "sections": [ + { + "background_color": "#FFFFFF", + "rows": [ + { + "layout": "1 Column", + "cells": [ + {"blocks": [{"kind": "text", "html": "

Hello

"}]} + ], + }, + { + "layout": "2 Columns (1/3 and 2/3)", + "cells": [ + { + "blocks": [ + { + "kind": "image", + "file": str(png_path), + "alt": "logo", + } + ] + }, + { + "blocks": [ + { + "kind": "button", + "label": "Go", + "url": "https://example.com", + } + ] + }, + ], + }, + ], + } + ], + } + ) + + # Real write #1: the image upload (source="public_image", is_public=True). + resolved_sections = ec.resolve_spec_images(spec) + image_block = resolved_sections[0]["rows"][1]["cells"][0]["blocks"][0] + file_id = image_block["file_id"] + assert file_id, "upload_email_image did not return a file id" + scratch.track("file", file_id, lambda: files_api.delete_file(drift_client, file_id)) + assert image_block["natural_width"] == 9 + assert image_block["natural_height"] == 7 + + # The emitted Image.src must be reachable by a real recipient — no Kizen + # auth headers on this request at all. Without is_public=True on the + # upload, this 404s (the finding this test exists to catch). + unauth = httpx.get(image_block["src"]) + assert unauth.status_code == 200, ( + f"uploaded image is not publicly readable: {unauth.status_code} " + f"for {image_block['src']}" + ) + + # Real write #2: the template itself, via the exact planner + apply_plan + # the CLI's `messages templates create` command uses. + plan = message_planners.plan_create_template_from_spec(spec, resolved_sections) + result = apply_plan(plan) + assert result.all_ok, [r.message for r in result.results if r.status != "ok"] + template_id = result.results[0].server_uuid + assert template_id + scratch.track( + "email template", + template_id, + lambda: messages_api.delete_template(drift_client, template_id), + ) + + # Read it back and run the same drift check `messages templates get` does. + live = messages_api.get_template(drift_client, template_id) + summary = craft_summary(live) + assert summary["structure_coupled"] is True, summary + assert summary["text_in_sync"] is True, summary + assert summary["coupled"] is True + + row_nodes = [ + n + for n in live["craft_json"].values() + if isinstance(n, dict) and n.get("type", {}).get("resolvedName") == "Row" + ] + fractions_seen = {tuple(n["props"]["columns"]) for n in row_nodes} + assert (1,) in fractions_seen + assert (0.3333333333333333, 0.6666666666666666) in fractions_seen + + # The stored content's column-width rule must be a BASE rule, not only + # inside the mobile @media query — confirms the fix against the real + # stored payload, not just the offline emitter. The breakpoint itself + # tracks `EMAIL_ROOT_PROPS["mobileBreak"]` ("414"), not the pre-BCLI-025 + # hardcoded 480 (see `tools.email_craft._compile_html`). + # `.index('", start)] + if "@media" not in style: + return style, "" + base, media = style.split("@media only screen and (max-width:", 1) + _breakpoint, media = media.split("px){", 1) + # Strip exactly the one closing brace that ends the @media block itself + # (not a rule's own closing brace). + return base, media[:-1] if media.endswith("}") else media + + +def _resolved_name(node: dict) -> str: + t = node.get("type") + return t.get("resolvedName") if isinstance(t, dict) else t + + +def _ids_of(craft_json: dict, *kinds: str) -> set[str]: + return {nid for nid, node in craft_json.items() if _resolved_name(node) in kinds} + + +# --------------------------------------------------------------------------- +# Synthetic PNG/JPEG builders (no external dependency, no captured data) +# --------------------------------------------------------------------------- + + +def _make_png(width: int, height: int) -> bytes: + sig = b"\x89PNG\r\n\x1a\n" + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + raw = (b"\x00" + b"\xff\x00\x00" * width) * height + idat = zlib.compress(raw) + return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + + +def _make_jpeg(width: int, height: int) -> bytes: + soi = b"\xff\xd8" + sof0_payload = ( + struct.pack(">B", 8) + + struct.pack(">H", height) + + struct.pack(">H", width) + + struct.pack(">B", 1) + + struct.pack(">BBB", 1, 0x11, 0) + ) + sof0 = b"\xff\xc0" + struct.pack(">H", len(sof0_payload) + 2) + sof0_payload + return soi + sof0 + + +# --------------------------------------------------------------------------- +# Column-layout table — byte-exact, confirmed live 2026-08-25 +# --------------------------------------------------------------------------- + + +def test_v1_layouts_are_exactly_the_four_confirmed_live(): + assert ec.known_layouts() == [ + "1 Column", + "2 Columns", + "2 Columns (1/3 and 2/3)", + "2 Columns (2/3 and 1/3)", + ] + + +def test_column_fractions_are_byte_exact_not_rounded(): + assert ec.COLUMN_LAYOUTS["1 Column"].columns == (1,) + assert ec.COLUMN_LAYOUTS["2 Columns"].columns == (0.5, 0.5) + assert ec.COLUMN_LAYOUTS["2 Columns (1/3 and 2/3)"].columns == ( + 0.3333333333333333, + 0.6666666666666666, + ) + assert ec.COLUMN_LAYOUTS["2 Columns (2/3 and 1/3)"].columns == ( + 0.6666666666666666, + 0.3333333333333333, + ) + # The spec model's own table must agree with the emitter's. + for name, layout in ec.COLUMN_LAYOUTS.items(): + assert COLUMN_FRACTIONS[name] == layout.columns + + +def test_two_thirds_one_third_compiled_markup_matches_live_probe(): + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell([ec.text_block("

a

")]), + ec.cell([ec.text_block("

b

")]), + ], + layout="2 Columns (1/3 and 2/3)", + ) + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + base_css, media_css = _split_style_block(content) + assert "mj-column-per-33-333332" in content + assert "mj-column-per-66-666664" in content + # The column widths are BASE (unconditional) rules — this is what makes + # the row render side by side at desktop width, not just under 480px. + assert "width:33.333332% !important; max-width:33.333332%;" in base_css + assert "width:66.666664% !important; max-width:66.666664%;" in base_css + assert "33.333332%" not in media_css + assert "66.666664%" not in media_css + # The media query's job is to COLLAPSE both columns to full width. + assert ( + ".mj-column-per-33-333332 { width:100% !important; max-width:100%; }" + in media_css + ) + assert ( + ".mj-column-per-66-666664 { width:100% !important; max-width:100%; }" + in media_css + ) + assert "width:293.3333px;" in content + assert "width:586.6666px;" in content + + +def test_one_column_and_two_column_compiled_markup_matches_live_probe(): + sections = [ + ec.section( + [ + ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column"), + ec.row( + [ + ec.cell([ec.text_block("

a

")]), + ec.cell([ec.text_block("

b

")]), + ], + layout="2 Columns", + ), + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + base_css, media_css = _split_style_block(content) + assert "mj-column-per-100" in content + # 880px, not 880.0px, at every width call site in `_render_row` — + # `_row_content_width_px`'s two sites and the per-column `mso_widths_px` + # (`_truncate4`'s output) alike. See the float-formatting fix (BCLI-025 + # item 7). + assert 'role="presentation" style="width:880px;">
+ assert "width:880.0px;" not in content + # 2 Columns: one base rule + one media-collapse rule + one + # `.moz-text-html`-prefixed rule (BCLI-025 item 2) + one div per column + # = 5 occurrences of the class name. + assert content.count("mj-column-per-50") == 5 + assert ".mj-column-per-50 { width:50% !important; max-width:50%; }" in base_css + assert ".mj-column-per-50 { width:100% !important; max-width:100%; }" in media_css + assert "width:50%" not in media_css + # The 2-Columns row's own two `content_width_px` call sites are unchanged + # from the 1-Column row above (same Section, so still 880/880px, not + # split). "440px" here is the per-column `mso_widths_px` (`_truncate4`, + # 0.5 * 880), once per column. + assert content.count("width:440px;") == 2 + assert "width:440.0px;" not in content + assert not re.search(r"\.0px", content) + + +def test_button_and_divider_compiled_markup_matches_a_real_captured_template(): + """Verified 2026-08-26, read-only, against a real Kizen-authored + template on `cli-testing`: `_render_button`/`_render_divider`'s output + is byte-exact against that template's actual compiled `content` for a + Button/Divider node carrying the same props. The real template's own + copy/URL/color never enter this repo (personal-data rule) — the values + below are synthetic, chosen to exercise the same code path. + + **Corrected 2026-08-26, same-day, in BCLI-024 review**: the original + assertion here was missing the button table's own `align="center"` and + `line-height:100%;` — both confirmed present in Kizen's real compile of + the same button by independent comparison, and both now load-bearing + for `Button.alignment` actually reaching `content` (see + `test_content_reflects_every_layout_prop_this_item_added` below).""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.button_block( + "Click Here", "https://example.com", color="#1B64F2" + ) + ] + ), + ec.cell([ec.divider_block("#E5E7EB")]), + ], + layout="2 Columns", + ) + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + assert ( + '' + "
Click Here
' + ) in content + assert ( + '

' + ) in content + + +def test_render_image_fixed_width_compiled_markup_matches_the_reference_shape(): + """`_render_image`'s output shape, re-derived from the reference + template's real compiled `content` (read-only `GET`, 2026-08-26) — see + the work item's report for the full trace. Kizen wraps every Image in a + two-level table (`
` carrying block padding, a nested + `
` around the ``), not a bare `` + tag as this emitter produced before BCLI-025 — the ``'s own + attributes/style are byte-exact against the reference; the wrapper is + asserted structurally (tag/attribute placement), not as one giant + pinned string, so a future formatting-only tweak to the wrapper doesn't + make this test as brittle as re-deriving the whole thing by hand.""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.image_block( + file_id="f1", + src="https://example.com/logo.png", + name="logo.png", + alt="Logo", + natural_width=200, + natural_height=100, + width=150, + ) + ] + ) + ], + layout="1 Column", + ) + ] + ) + ] + craft_json, content = ec.build_email_content(sections) + assert ( + 'Logo' + ) in content + assert "data-natural-width" not in content + assert "data-natural-height" not in content + + image_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Image" + ) + # Fixed mode: no `-auto` class, and no auto-mode CSS rule at all. + assert f'class="{eh._image_auto_class(image_id)}"' not in content + assert f".{eh._image_auto_class(image_id)} > table td" not in content + # The outer block-level carries the image's own containerPadding. + outer_td = re.search( + r'', + content, + ) + assert outer_td, "no block-level wrapper found around the Image" + assert outer_td.groups() == ("10", "10", "10", "10") + # The inner nested table's own carries the img's pixel width. + assert '' in content + + +def test_render_image_with_link_wraps_the_whole_table_in_an_anchor(): + """Pins current output shape; not verified against a real captured + template. The reference template's one worked Image example (the hero) + has no `link`, so this case has never been checked against Kizen's own + compiled `content` — this test only guards against `_render_image`'s + ``-wrapping silently changing shape, e.g. reverting to wrapping just + the bare `` instead of the whole two-level table.""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.image_block( + file_id="f1", + src="https://example.com/logo.png", + name="logo.png", + alt="Logo", + link="https://example.com/landing", + natural_width=200, + natural_height=100, + width=150, + ) + ] + ) + ], + layout="1 Column", + ) + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + anchor_open = '' + assert anchor_open in content + start = content.index(anchor_open) + len(anchor_open) + # The anchor wraps the ENTIRE image table, not just the tag — + # everything between the anchor open and its matching close is the + # two-level table this fix is pinning, ending right where the 's + # own markup does. + assert content[start : start + len('", start) + len("/>") + assert content[ + img_end : img_end + len("
") + ] == ("
") + + +def test_render_image_auto_mode_uses_section_container_width_and_natural_width_rule(): + """Auto mode (`width` omitted in the spec): the ``'s `width` + attribute becomes the parent Section's own `containerWidth`, and a + `.image--auto > table td` rule caps it at the image's own + `naturalWidth` — both confirmed against the reference template's one + real auto-mode Image node (`containerWidth: 600`, `naturalWidth: 1200` + -> `width="600"` and `max-width: 1200px` in that exact rule shape).""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.image_block( + file_id="f1", + src="https://example.com/hero.png", + name="hero.png", + alt="Hero", + natural_width=1200, + natural_height=630, + ) + ] + ) + ], + layout="1 Column", + container_width="600", + ) + ], + container_width="900", + ) + ] + craft_json, content = ec.build_email_content(sections) + image_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Image" + ) + assert "width" not in craft_json[image_id]["props"] + assert craft_json[image_id]["props"]["size"] == "auto" + + auto_class = eh._image_auto_class(image_id) + assert f'class="{auto_class}"' in content + assert ( + f".{auto_class} > table td {{ width: 100% !important; max-width: 1200px; }}" + in content + ) + # Auto width comes from the Row's parent SECTION's own containerWidth + # (900), not the Row's own containerWidth (600) and not the image's own + # naturalWidth (1200) — the fallback chain's first, most-specific rung. + assert ( + 'Hero' in content + + +def test_render_image_auto_mode_falls_back_to_root_max_width_when_section_unset(): + """When the parent Section has no explicit `container_width` at all + (the common case for an unmodified spec), auto mode falls back to + `Root.props.maxWidth`. Inferred, not observed live — the work item's + Open questions flags this as the fallback with the weakest evidence in + this item, since the reference template's one auto-mode image had an + explicit `Section.containerWidth` set.""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.image_block( + file_id="f1", + src="https://example.com/hero.png", + name="hero.png", + natural_width=1200, + natural_height=630, + ) + ] + ) + ], + layout="1 Column", + ) + ] + ) + ] + craft_json, content = ec.build_email_content(sections) + section_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ) + assert "containerWidth" not in craft_json[section_id]["props"] + # EMAIL_ROOT_PROPS["maxWidth"] == "900". + assert 'width="900"' in content + assert '' in content + + +def test_image_and_section_class_conventions_share_id_formatting(): + """The `.image--auto` and `.section-` conventions must + not drift apart from each other — pinned here, together, per the work + item's explicit constraint, rather than trusting two independently + hand-rolled f-strings to stay in sync. Both wrap the SAME node id in + the SAME `-[-suffix]` shape.""" + for node_id in ["abc123", "0" * 24, "a-node-with-dashes"]: + assert eh._section_class(node_id) == f"section-{node_id}" + assert eh._image_auto_class(node_id) == f"image-{node_id}-auto" + + +def test_exactly_one_tr_per_row_regardless_of_column_count(): + sections = [ + ec.section( + [ + ec.row( + [ec.cell([]), ec.cell([])], + layout="2 Columns", + ) + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + # One opening mso for the row's own mso table, not one per column. + assert content.count("") == 1 + + +# --------------------------------------------------------------------------- +# The coupling rule: Section/Row ids <-> section- classes, both ways +# --------------------------------------------------------------------------- + + +def test_every_section_and_row_id_has_a_matching_class_and_vice_versa(): + sections = [ + ec.section( + [ + ec.row([ec.cell([ec.text_block("

one

")])], layout="1 Column"), + ec.row( + [ + ec.cell([ec.button_block("Go", "https://example.com")]), + ec.cell([ec.divider_block()]), + ], + layout="2 Columns", + ), + ] + ), + ec.section( + [ec.row([ec.cell([ec.text_block("

two

")])], layout="1 Column")] + ), + ] + craft_json, content = ec.build_email_content(sections) + node_ids = _ids_of(craft_json, "Section", "Row") + html_classes = set(_SECTION_CLASS.findall(content)) + assert node_ids == html_classes + # 2 sections + 3 rows (2 in the first section, 1 in the second). + assert len(node_ids) == 5 + + +def test_craft_summary_reports_coupled_and_text_in_sync_for_emitted_output(): + """The real end-to-end check: run the drift detector this item reuses + (`craft_summary`, unmodified) against this emitter's own output.""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

Hello World

")])], + layout="1 Column", + ), + ec.row( + [ + ec.cell([ec.button_block("Go", "https://example.com")]), + ec.cell([ec.divider_block()]), + ], + layout="2 Columns", + ), + ] + ) + ] + craft_json, content = ec.build_email_content(sections) + summary = craft_summary({"craft_json": craft_json, "content": content}) + assert summary["structure_coupled"] is True + assert summary["text_in_sync"] is True + assert summary["coupled"] is True + + +def test_text_block_html_is_embedded_verbatim_in_content(): + html = '

Hi there

' + sections = [ + ec.section([ec.row([ec.cell([ec.text_block(html)])], layout="1 Column")]) + ] + _craft_json, content = ec.build_email_content(sections) + assert html in content + + +# --------------------------------------------------------------------------- +# Cell.props.__width — the additive form_ui hook this item adds +# --------------------------------------------------------------------------- + + +def test_cell_props_carries_double_underscore_width_matching_row_columns(): + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell([ec.text_block("

a

")]), + ec.cell([ec.text_block("

b

")]), + ], + layout="2 Columns (2/3 and 1/3)", + ) + ] + ) + ] + craft_json, _content = ec.build_email_content(sections) + cells = [n for n in craft_json.values() if _resolved_name(n) == "Cell"] + widths = sorted(c["props"]["__width"] for c in cells) + assert widths == sorted([0.6666666666666666, 0.3333333333333333]) + + +def test_form_ui_cell_props_hook_defaults_to_empty_dict_props(): + """Regression: the hook this module relies on must default to today's + forms/layouts behaviour when not passed — see + tests/test_form_ui_payloads.py and tests/test_layout_custom_content.py + for the full guard.""" + tree = form_ui.build_content_tree( + [form_ui.section([form_ui.row([form_ui.cell([form_ui.text_block("x")])])])] + ) + cell = next( + n for n in tree.values() if n.get("type", {}).get("resolvedName") == "Cell" + ) + assert cell["props"] == {} + + +# --------------------------------------------------------------------------- +# Validation: row/layout, unsupported block kind — never a silent reshape +# --------------------------------------------------------------------------- + + +def test_row_rejects_cell_count_mismatch_for_its_layout(): + with pytest.raises(ValueError, match="needs 2 cell"): + ec.row([ec.cell([])], layout="2 Columns") + + +def test_row_rejects_unknown_layout_name(): + with pytest.raises(ValueError, match="unknown row layout"): + ec.row([ec.cell([])], layout="Fancy Layout") + + +@pytest.mark.parametrize( + "layout", + ["3 Columns", "3 Columns (gutters)", "4 Columns", "5 Columns", "6 Columns"], +) +def test_row_rejects_out_of_v1_scope_layouts_by_name(layout): + with pytest.raises(ValueError, match="out of v1 scope"): + ec.row([ec.cell([])], layout=layout) + + +def test_unsupported_block_kind_fails_loudly(): + with pytest.raises(ValueError, match="unsupported email block kind"): + ec.build_email_content( + [ + ec.section( + [ec.row([ec.cell([{"kind": "attachments"}])], layout="1 Column")] + ) + ] + ) + + +# --------------------------------------------------------------------------- +# Image header-byte dimension parsing — PNG and JPEG, GIF/WebP/SVG rejected +# --------------------------------------------------------------------------- + + +def test_png_dimensions_read_from_header_bytes(): + png = _make_png(37, 21) + assert ec.read_image_dimensions(png) == (37, 21, "image/png") + + +def test_jpeg_dimensions_read_from_sof0_segment(): + jpg = _make_jpeg(64, 48) + assert ec.read_image_dimensions(jpg) == (64, 48, "image/jpeg") + + +@pytest.mark.parametrize( + "label,data", + [ + ("gif", b"GIF89a" + b"\x00" * 20), + ("webp", b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 20), + ("svg", b''), + ("bogus", b"not an image at all, just text"), + ], +) +def test_unsupported_image_formats_fail_loudly(label, data): + with pytest.raises(ValueError): + ec.read_image_dimensions(data) + + +def test_offline_resolve_spec_images_reads_local_dims_without_uploading(tmp_path: Path): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_make_png(64, 32)) + spec = EmailTemplateDef.model_validate( + { + "name": "Synthetic", + "sections": [ + { + "rows": [ + { + "layout": "1 Column", + "cells": [ + { + "blocks": [ + { + "kind": "image", + "file": str(png_path), + "alt": "logo", + } + ] + } + ], + } + ] + } + ], + } + ) + resolved = ec.offline_resolve_spec_images(spec) + sections = ec.assemble_sections(resolved) + craft_json, _content = ec.build_email_content(sections) + image_node = next(n for n in craft_json.values() if _resolved_name(n) == "Image") + assert image_node["props"]["naturalWidth"] == 64 + assert image_node["props"]["naturalHeight"] == 32 + assert image_node["props"]["fileId"] == ec.OFFLINE_FILE_PLACEHOLDER + assert image_node["props"]["alt"] == "logo" + + +# --------------------------------------------------------------------------- +# Golden fixture — deterministic ids, byte-exact output for a small synthetic +# template. Regression net for accidental format drift in the emitter. +# --------------------------------------------------------------------------- + + +def _props_of(craft_json: dict, kind: str) -> dict: + return next(n["props"] for n in craft_json.values() if _resolved_name(n) == kind) + + +def _all_props_of(craft_json: dict, kind: str) -> list[dict]: + return [n["props"] for n in craft_json.values() if _resolved_name(n) == kind] + + +# --------------------------------------------------------------------------- +# Layout props (BCLI-024) — actual emitted prop values and placement, not +# merely "the key exists somewhere". The regression test pins the +# all-defaults case byte-identical to the pre-this-item emitter's exact +# key set — see the model's own defaults for why (SectionDef.max_width is +# "900", not the reference's "600"). +# --------------------------------------------------------------------------- + + +def test_all_defaults_spec_is_byte_identical_to_the_pre_item_section_and_row_props(): + """Pins BCLI-024's own regression-safety acceptance criterion: a spec + that sets none of the new layout fields must reproduce the *exact* key + set `form_ui._assemble_section`/`_assemble_row` produced before this + item — in particular, no `containerWidth` key at all (it was never + written before this item added the field).""" + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + craft_json, _content = ec.build_email_content(sections) + + section_props = _props_of(craft_json, "Section") + assert section_props["maxWidth"] == "900" + assert section_props["width"] == "100" + assert "containerWidth" not in section_props + assert section_props["containerPaddingTop"] == "10" + assert section_props["containerPaddingRight"] == "10" + assert section_props["containerPaddingBottom"] == "10" + assert section_props["containerPaddingLeft"] == "10" + + row_props = _props_of(craft_json, "Row") + assert row_props["maxWidth"] == "900" + assert row_props["width"] == "100" + assert "containerWidth" not in row_props + assert row_props["containerPaddingTop"] == "10" + assert row_props["containerPaddingRight"] == "10" + assert row_props["containerPaddingBottom"] == "10" + assert row_props["containerPaddingLeft"] == "10" + + +def test_section_layout_props_are_emitted_when_set_matching_the_reference_pattern(): + """Values chosen to match the pattern independently confirmed live + against the reference template's `Section` nodes (BCLI-024 Context): + `maxWidth: '600'`, `containerWidth: '900'`, uniform padding `10` (or + `0` on the one full-bleed section observed).""" + sections = [ + ec.section( + [ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")], + max_width="600", + container_width="900", + padding={"top": "0", "right": "0", "bottom": "0", "left": "0"}, + ) + ] + craft_json, _content = ec.build_email_content(sections) + props = _props_of(craft_json, "Section") + assert props["maxWidth"] == "600" + assert props["containerWidth"] == "900" + assert props["containerPaddingTop"] == "0" + assert props["containerPaddingRight"] == "0" + assert props["containerPaddingBottom"] == "0" + assert props["containerPaddingLeft"] == "0" + + +def test_row_layout_props_are_emitted_when_set_including_asymmetric_padding(): + """Values chosen to match the reference's non-uniform rows (BCLI-024 + Context): one row at `width: '75'` (not `'100'`), one row with + asymmetric `containerPaddingLeft/Right: '40'` against `'10'` + top/bottom — `Row` props are independent, not derived from `Section`.""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

x

")])], + layout="1 Column", + width="75", + container_width="600", + padding={"top": "10", "right": "40", "bottom": "10", "left": "40"}, + ) + ] + ) + ] + craft_json, _content = ec.build_email_content(sections) + props = _props_of(craft_json, "Row") + assert props["width"] == "75" + assert props["containerWidth"] == "600" + assert props["containerPaddingTop"] == "10" + assert props["containerPaddingRight"] == "40" + assert props["containerPaddingBottom"] == "10" + assert props["containerPaddingLeft"] == "40" + + +def test_row_layout_props_are_independent_per_row_not_uniform_across_a_section(): + """The load-bearing design fact this item's Context established: `Row` + layout props don't follow a clean formula from the parent `Section`, so + two rows in the same section can carry different values.""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

a

")])], + layout="1 Column", + container_width="580", + ), + ec.row( + [ec.cell([ec.text_block("

b

")])], + layout="1 Column", + container_width="600", + ), + ] + ) + ] + craft_json, _content = ec.build_email_content(sections) + widths = sorted(p["containerWidth"] for p in _all_props_of(craft_json, "Row")) + assert widths == ["580", "600"] + + +# --------------------------------------------------------------------------- +# Compiled `content` must track the SAME width `craft_json` carries — a +# blocking defect found in review: `content`'s mso table/media widths were +# still pinned to a module-level 880px constant, never reacting to +# `Section.max_width`/`Row.container_width`/padding, so a spec with +# `max_width: "600"` produced a `craft_json` that renders at 600px in +# Kizen's builder and a `content` that still renders at 880px — the exact +# two-fields-disagree failure this whole surface exists to prevent +# (`craft_summary()` can't see it: node ids and text both still match). +# --------------------------------------------------------------------------- + + +def _row_style_max_width_px(content: str, row_id: str) -> str: + m = re.search(rf"\.section-{row_id} \{{ max-width:([0-9.]+)px; \}}", content) + assert m, f"no max-width rule found for row {row_id}" + return m.group(1) + + +def _mso_table_width_px(content: str, row_id: str) -> str: + # The row's own opening
is + # immediately followed by its mso table; scope the search to that row's + # own fragment so a multi-row template can't match a sibling row's + # table. Matches on the class prefix only (not a full tag), since the + # div also carries a `style="padding:...;"` attribute. + start = content.index(f'
str: + m = re.search(rf'
str: + m = re.search( + rf'
x

")])], layout="1 Column")], + max_width="600", + ) + ] + craft_json, content = ec.build_email_content(sections) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _row_style_max_width_px(content, row_id) == "580" + assert _mso_table_width_px(content, row_id) == "580" + + +def test_compiled_content_row_width_tracks_section_max_width_with_zero_padding(): + """Same section `max_width`, but full-bleed (`padding: 0/0/0/0`) — must + compile to 600px, not 580px and not 880px.""" + sections = [ + ec.section( + [ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")], + max_width="600", + padding={"top": "0", "right": "0", "bottom": "0", "left": "0"}, + ) + ] + craft_json, content = ec.build_email_content(sections) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _row_style_max_width_px(content, row_id) == "600" + assert _mso_table_width_px(content, row_id) == "600" + + +def test_compiled_content_row_width_defaults_to_880_matching_pre_bcli_024_output(): + """No overrides at all: must still compile to 880px — today's exact + pre-existing hardcoded value, now *derived* (900 Section maxWidth - 10 - + 10 padding) rather than frozen as a constant.""" + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + craft_json, content = ec.build_email_content(sections) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _row_style_max_width_px(content, row_id) == "880" + assert _mso_table_width_px(content, row_id) == "880" + + +def test_compiled_content_row_width_prefers_explicit_row_container_width_over_section(): + """An explicit `Row.container_width` is trusted directly, even when it + disagrees with what the Section-derived formula would produce — + matching BCLI-024's "independent, explicit fields" design, the same + trust model as `Row.props.columns`/`Cell.props.__width`.""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

x

")])], + layout="1 Column", + container_width="450", + ) + ], + max_width="600", + ) + ] + craft_json, content = ec.build_email_content(sections) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert craft_json[row_id]["props"]["containerWidth"] == "450" + assert _row_style_max_width_px(content, row_id) == "450" + assert _mso_table_width_px(content, row_id) == "450" + + +def test_compiled_content_two_rows_in_one_template_get_independent_widths(): + """Two sections with different `max_width`/padding in the SAME + template compile to two DIFFERENT row widths in `content` — proving + the width is computed per row, not held as one module-level value that + the last section computed would silently apply to every row.""" + sections = [ + ec.section( + [ec.row([ec.cell([ec.text_block("

a

")])], layout="1 Column")], + max_width="600", + ), + ec.section( + [ec.row([ec.cell([ec.text_block("

b

")])], layout="1 Column")], + max_width="600", + padding={"top": "0", "right": "0", "bottom": "0", "left": "0"}, + ), + ] + craft_json, content = ec.build_email_content(sections) + row_ids = [nid for nid, n in craft_json.items() if _resolved_name(n) == "Row"] + assert len(row_ids) == 2 + widths = sorted(_row_style_max_width_px(content, rid) for rid in row_ids) + assert widths == ["580", "600"] + + +def test_compiled_content_column_split_truncates_to_four_decimals_at_a_non_default_width(): + """The mso per-column pixel split must use the SAME truncate-to-4- + decimals convention the live-confirmed 880px defaults already used + (`293.3333`, not the rounded `293.3334` or the full-precision + `293.3333333333333`), generalized to a non-880 row width.""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell([ec.text_block("

a

")]), + ec.cell([ec.text_block("

b

")]), + ], + layout="2 Columns (1/3 and 2/3)", + container_width="580", + ) + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + assert "width:193.3333px;" in content + assert "width:386.6666px;" in content + + +# --------------------------------------------------------------------------- +# Compiled `content` must also carry `Section`/`Row` padding — a second +# blocking defect found in review, same root cause as the width bug: +# `_render_row`/`_render_section` built their markup without ever reading +# `containerPadding{Top,Right,Bottom,Left}`, so `content` had NO padding +# declaration for `Section`/`Row` at all (text rendered flush against the +# canvas edge), regardless of what `craft_json` said. +# --------------------------------------------------------------------------- + + +def test_compiled_content_section_padding_matches_craft_json_when_set(): + sections = [ + ec.section( + [ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")], + padding={"top": "0", "right": "0", "bottom": "0", "left": "0"}, + ) + ] + craft_json, content = ec.build_email_content(sections) + section_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ) + assert _section_padding_css(content, section_id) == "0px 0px 0px 0px" + + +def test_compiled_content_row_padding_matches_craft_json_when_set(): + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

x

")])], + layout="1 Column", + padding={"top": "20", "right": "20", "bottom": "20", "left": "20"}, + ) + ] + ) + ] + craft_json, content = ec.build_email_content(sections) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _row_padding_css(content, row_id) == "20px 20px 20px 20px" + + +def test_compiled_content_padding_defaults_to_uniform_10_matching_craft_json(): + """No overrides at all: `content` must still carry the SAME uniform + `'10'` padding `craft_json` has always defaulted to — this is not a new + field's default, it's a pre-existing prop that `content` simply never + rendered before this fix, on every section and row, not just ones that + explicitly set the new `padding` field.""" + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + craft_json, content = ec.build_email_content(sections) + section_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _section_padding_css(content, section_id) == "10px 10px 10px 10px" + assert _row_padding_css(content, row_id) == "10px 10px 10px 10px" + + +def test_compiled_content_padding_matches_craft_json_for_the_asymmetric_case(): + """The coordinator's own repro shape: left/right differ from top/bottom + (`20/30/20/30`, not a uniform value) — a fix that only handles the + uniform default would pass the two tests above and still fail this + one.""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

x

")])], + layout="1 Column", + padding={"top": "20", "right": "30", "bottom": "20", "left": "30"}, + ) + ], + padding={"top": "10", "right": "40", "bottom": "10", "left": "40"}, + ) + ] + craft_json, content = ec.build_email_content(sections) + section_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _section_padding_css(content, section_id) == "10px 40px 10px 40px" + assert _row_padding_css(content, row_id) == "20px 30px 20px 30px" + + +def test_compiled_content_padding_is_independent_per_row_and_section(): + """Two sections, each with its own row, all four carrying different + padding — proving padding is read per node, not one value leaking + across the template (the same class of bug the width fix already + guarded against).""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

a

")])], + layout="1 Column", + padding={"top": "20", "right": "20", "bottom": "20", "left": "20"}, + ) + ], + padding={"top": "10", "right": "10", "bottom": "10", "left": "10"}, + ), + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

b

")])], + layout="1 Column", + padding={"top": "30", "right": "30", "bottom": "30", "left": "30"}, + ) + ], + padding={"top": "0", "right": "0", "bottom": "0", "left": "0"}, + ), + ] + craft_json, content = ec.build_email_content(sections) + section_ids = [ + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ] + row_ids = [nid for nid, n in craft_json.items() if _resolved_name(n) == "Row"] + section_paddings = sorted(_section_padding_css(content, sid) for sid in section_ids) + row_paddings = sorted(_row_padding_css(content, rid) for rid in row_ids) + assert section_paddings == ["0px 0px 0px 0px", "10px 10px 10px 10px"] + assert row_paddings == ["20px 20px 20px 20px", "30px 30px 30px 30px"] + + +def test_divider_size_is_emitted_when_set(): + sections = [ + ec.section([ec.row([ec.cell([ec.divider_block(size="1")])], layout="1 Column")]) + ] + craft_json, content = ec.build_email_content(sections) + props = _props_of(craft_json, "Divider") + assert props["size"] == "1" + assert "border-top:1px solid" in content + + +def test_divider_size_defaults_to_3_when_unset(): + sections = [ + ec.section([ec.row([ec.cell([ec.divider_block()])], layout="1 Column")]) + ] + craft_json, _content = ec.build_email_content(sections) + assert _props_of(craft_json, "Divider")["size"] == "3" + + +def test_button_layout_props_are_emitted_when_set_matching_the_reference_pattern(): + """Values chosen to match the pattern independently confirmed live + against the reference template's `Button` node (BCLI-024 Context): + `borderRadius: '20'`, `padding{Left,Right}: '30'`, `alignment: 'left'`.""" + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.button_block( + "Go", + "https://example.com", + border_radius="20", + padding_left="30", + padding_right="30", + alignment="left", + ) + ] + ) + ], + layout="1 Column", + ) + ] + ) + ] + craft_json, content = ec.build_email_content(sections) + props = _props_of(craft_json, "Button") + assert props["borderRadius"] == "20" + assert props["paddingLeft"] == "30" + assert props["paddingRight"] == "30" + assert props["alignment"] == "left" + assert "border-radius:20px;" in content + assert "padding:10px 30px 10px 30px;" in content + + +def test_button_layout_props_default_to_todays_hardcoded_values_when_unset(): + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.button_block("Go", "https://example.com")])], + layout="1 Column", + ) + ] + ) + ] + craft_json, _content = ec.build_email_content(sections) + props = _props_of(craft_json, "Button") + assert props["borderRadius"] == "8" + assert props["paddingLeft"] == "20" + assert props["paddingRight"] == "20" + assert props["alignment"] == "center" + + +def test_image_layout_props_are_emitted_only_when_set(): + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.image_block( + file_id="f1", + src="https://host/api/files/f1/download", + name="logo.png", + container_width="580", + max_width="300", + max_height="200", + ) + ] + ) + ], + layout="1 Column", + ) + ] + ) + ] + craft_json, _content = ec.build_email_content(sections) + props = _props_of(craft_json, "Image") + assert props["containerWidth"] == "580" + assert props["maxWidth"] == "300" + assert props["maxHeight"] == "200" + + +def test_image_layout_props_are_absent_by_default_matching_todays_output(): + sections = [ + ec.section( + [ + ec.row( + [ + ec.cell( + [ + ec.image_block( + file_id="f1", + src="https://host/api/files/f1/download", + name="logo.png", + ) + ] + ) + ], + layout="1 Column", + ) + ] + ) + ] + craft_json, _content = ec.build_email_content(sections) + props = _props_of(craft_json, "Image") + assert "containerWidth" not in props + assert "maxWidth" not in props + assert "maxHeight" not in props + + +def test_spec_driven_layout_props_flow_end_to_end_from_email_template_def(): + """The full path this item wires: `EmailTemplateDef` -> `_walk_blocks` + -> `assemble_sections` -> `build_email_content`, for one section/row + carrying every new field at once.""" + spec = EmailTemplateDef.model_validate( + { + "name": "Newsletter", + "sections": [ + { + "max_width": "600", + "container_width": "900", + "padding": {"top": "0", "right": "0", "bottom": "0", "left": "0"}, + "rows": [ + { + "layout": "1 Column", + "width": "75", + "container_width": "580", + "padding": { + "top": "10", + "right": "40", + "bottom": "10", + "left": "40", + }, + "cells": [ + { + "blocks": [ + { + "kind": "button", + "label": "Go", + "url": "https://x", + "border_radius": "20", + "padding_left": "30", + "padding_right": "30", + "alignment": "left", + } + ] + } + ], + } + ], + } + ], + } + ) + resolved = ec.offline_resolve_spec_images(spec) + sections = ec.assemble_sections(resolved) + craft_json, _content = ec.build_email_content(sections) + + section_props = _props_of(craft_json, "Section") + assert section_props["maxWidth"] == "600" + assert section_props["containerWidth"] == "900" + + row_props = _props_of(craft_json, "Row") + assert row_props["width"] == "75" + assert row_props["containerWidth"] == "580" + assert row_props["containerPaddingRight"] == "40" + + button_props = _props_of(craft_json, "Button") + assert button_props["borderRadius"] == "20" + assert button_props["alignment"] == "left" + + +# --------------------------------------------------------------------------- +# Systematic content-coverage test — added in review after THREE separate +# craft_json-vs-content divergences were found by hand (width, then padding, +# then Button.alignment/Image.position): a new field landing correctly in +# craft_json and silently never reaching content is this item's recurring +# failure mode, and it was invisible to every acceptance test up to this +# point because the original reference-diff only ever compared craft_json. +# This test walks every field BCLI-024 added and asserts its value reaches +# content ON THE RIGHT NODE — not merely somewhere in the document — with a +# named, commented exemption for any field confirmed to be craft_json-only +# by design, never a silent pass. +# --------------------------------------------------------------------------- + + +def test_content_reflects_every_layout_prop_this_item_added(): + """One fixture, every new field set to a distinctive, individually + identifiable value, so a bug that reads the wrong node's prop (not just + "no prop at all") would also be caught.""" + + def resolved(n: dict) -> str: + t = n.get("type") + return t.get("resolvedName") if isinstance(t, dict) else t + + sections = [ + ec.section( + [ + # Row A: no explicit container_width, so Section.max_width's + # effect on the compiled row width is directly observable + # (isolated from Row B's own explicit override below). + ec.row( + [ + ec.cell( + [ + ec.divider_block(size="7"), + ec.button_block( + "Go", + "https://example.com", + border_radius="31", + padding_left="33", + padding_right="37", + alignment="right", + ), + ec.image_block( + file_id="f1", + src="https://host/x", + name="x.png", + container_width="401", + max_width="403", + max_height="407", + ), + ] + ) + ], + layout="1 Column", + width="77", # exempt, see below + padding={"top": "21", "right": "23", "bottom": "27", "left": "29"}, + ), + # Row B: explicit container_width, proving it's trusted + # directly rather than only ever derived from the Section. + ec.row( + [ec.cell([ec.text_block("

b

")])], + layout="1 Column", + container_width="271", + ), + ], + max_width="543", + container_width="919", # exempt, see below + padding={"top": "11", "right": "13", "bottom": "17", "left": "19"}, + ) + ] + craft_json, content = ec.build_email_content(sections) + + section_id = next(nid for nid, n in craft_json.items() if resolved(n) == "Section") + row_ids = [nid for nid, n in craft_json.items() if resolved(n) == "Row"] + row_a = next( + rid for rid in row_ids if craft_json[rid]["props"].get("width") == "77" + ) + row_b = next( + rid + for rid in row_ids + if craft_json[rid]["props"].get("containerWidth") == "271" + ) + image_id = next(nid for nid, n in craft_json.items() if resolved(n) == "Image") + + # --- SectionDef.max_width: INDIRECT — with no Row.container_width + # override, Row A's derived width is 543 - 19 - 13 = 511.0 (Section's + # own left/right padding, not Row A's). + # --- RowDef.width: DIRECT — a genuine multiplier on top of that derived + # width, confirmed against the reference template (a `width: '75'` row + # there compiles narrower than its `containerWidth`, not equal to it). + # 511.0 * 0.77 = 393.47. + assert _row_style_max_width_px(content, row_a) == "393.47" + + # --- SectionDef.padding: DIRECT, on the Section's own wrapper div. + assert _section_padding_css(content, section_id) == "11px 13px 17px 19px" + + # --- RowDef.container_width (Row B): DIRECT, trusted over the + # Section-derived formula Row A exercises above. + assert _row_style_max_width_px(content, row_b) == "271" + + # --- RowDef.padding (Row A): DIRECT, on the Row's own wrapper div — + # independent of Section's own padding, asserted above as a different + # value (21/23/27/29 vs. 11/13/17/19). + assert _row_padding_css(content, row_a) == "21px 23px 27px 29px" + + # --- DividerBlockDef.size: DIRECT, in the compiled border-top rule. + assert "border-top:7px" in content + + # --- ButtonBlockDef.border_radius/padding_left/padding_right/alignment: + # DIRECT, all on the Button's own compiled table/anchor markup. Scoped + # to this button's own fragment (not "somewhere in content") by + # locating its distinctive align="right". + button_start = content.index('` always uses the emitter's own `width`/fixed + # `max-width:100%` pair — these three new props have no consumer in + # `_render_image`, and this one *is* confirmed absent from the + # reference's compiled content too). + assert craft_json[row_a]["props"]["width"] == "77" + assert craft_json[image_id]["props"]["containerWidth"] == "401" + assert craft_json[image_id]["props"]["maxWidth"] == "403" + assert craft_json[image_id]["props"]["maxHeight"] == "407" + + +# --------------------------------------------------------------------------- +# BCLI-025 — compiled `content` fidelity against Kizen's own compiler. +# Every fix below was checked against the reference template's REAL compiled +# `content` (read-only `GET`, `cli-testing`, 2026-08-26), not inferred from +# `craft_json` or from "no consumer exists in `_render_*`" — see that item's +# report for the trace. In binding priority order. +# --------------------------------------------------------------------------- + + +def test_font_family_reaches_every_text_blocks_wrapper(monkeypatch: pytest.MonkeyPatch): + """Divergence 1 (highest priority): compiled `content` carried NO + `font-family` for text at all before this fix — every recipient + rendered in the client's serif fallback. Fixed via the + `kizen-text-styles` wrapper div Kizen's own compiler uses (confirmed + structurally against the reference), sourced from `Root.props`, never + `TextBlockDef`/`_render_paragraphs` (BCLI-023's scope — untouched).""" + monkeypatch.setitem(ec.EMAIL_ROOT_PROPS, "fontFamily", "Georgia") + monkeypatch.setitem(ec.EMAIL_ROOT_PROPS, "color", "rgba(10,20,30,1)") + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + _craft_json, content = ec.build_email_content(sections) + assert ( + '
' + "

x

" + ) in content + + +def test_font_family_default_matches_todays_arial_value(): + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + _craft_json, content = ec.build_email_content(sections) + assert "font-family:Arial;font-size:14px;line-height:1;" in content + + +def test_rgba_to_hex_matches_the_two_conversions_confirmed_live(): + """`Root.props.color: rgba(74,86,96,1)` -> `#4a5660` and + `Root.props.linkColor: rgba(82,142,249,1)` -> `#528ef9` — both read + directly off the reference template's compiled `content`.""" + assert eh._rgba_to_hex("rgba(74,86,96,1)") == "#4a5660" + assert eh._rgba_to_hex("rgba(82,142,249,1)") == "#528ef9" + assert eh._rgba_to_hex("#FFFFFF") == "#FFFFFF" # passes through non-rgba unchanged + + +def test_moz_text_html_rule_exists_for_every_column_class_in_a_multi_column_layout(): + """Divergence 2: Gecko-based mail clients (Thunderbird and others) key + column-stacking behaviour off `.moz-text-html`-prefixed rules — real, + recipient-visible, but scoped to that client family. Parses the + compiled `', content + ) + assert moz_style, "no .moz-text-html style block found" + moz_css = moz_style.group(1) + assert ( + ".moz-text-html .mj-column-per-33-333332 " + "{ width:33.333332% !important; max-width:33.333332%; }" + ) in moz_css + assert ( + ".moz-text-html .mj-column-per-66-666664 " + "{ width:66.666664% !important; max-width:66.666664%; }" + ) in moz_css + + +def test_no_moz_text_html_style_block_when_template_has_no_rows(): + """`_moz_text_html_style_block` returns nothing when there are no `Row` + nodes to derive column classes from, rather than an empty/broken rule.""" + assert eh._moz_text_html_style_block({}, "414") == "" + + +def test_mjml_reset_block_is_present_and_byte_exact(): + """Divergence 3: the static MJML reset block (`#outlook a`, + `body{margin:0}`, `table,td{border-collapse}`, `img{...}`, + `p{display:block;margin:13px 0}`) — confirmed byte-exact against the + reference template's compiled `content`. No per-template data, so + byte-exact is the right bar here (same allowance the work item gives + the `.moz-text-html` rule's structural shape).""" + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + _craft_json, content = ec.build_email_content(sections) + assert ( + '" + ) in content + + +def test_mobile_break_breakpoint_reads_root_props_not_hardcoded_480( + monkeypatch: pytest.MonkeyPatch, +): + """Divergence 4: the mobile-collapse media query's breakpoint must come + from `craft_json["ROOT"]["props"]["mobileBreak"]`, not a hardcoded + `480`. `mobileBreak` isn't spec-settable today (no `EmailTemplateDef` + field for it), so this monkeypatches `EMAIL_ROOT_PROPS` directly, per + the work item's own suggested approach for this test.""" + monkeypatch.setitem(ec.EMAIL_ROOT_PROPS, "mobileBreak", "600") + sections = [ + ec.section( + [ + ec.row( + [ec.cell([]), ec.cell([])], + layout="2 Columns", + ) + ] + ) + ] + _craft_json, content = ec.build_email_content(sections) + assert "@media only screen and (max-width:600px){" in content + assert "@media only screen and (max-width:480px)" not in content + # The .moz-text-html block's min-width also tracks the same breakpoint. + assert 'media="screen and (min-width:600px)"' in content + + +def test_mobile_break_default_is_414_not_the_old_hardcoded_480(): + sections = [ec.section([ec.row([ec.cell([]), ec.cell([])], layout="2 Columns")])] + _craft_json, content = ec.build_email_content(sections) + assert "@media only screen and (max-width:414px){" in content + assert "@media only screen and (max-width:480px)" not in content + + +def test_body_background_color_reads_root_props(monkeypatch: pytest.MonkeyPatch): + """Divergence 5 (body-background half): `Root.props.backgroundColor` + must reach ``'s inline style — confirmed against the reference + that it also reaches an outer wrapping `
`, not just ``.""" + monkeypatch.setitem(ec.EMAIL_ROOT_PROPS, "backgroundColor", "#112233") + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + _craft_json, content = ec.build_email_content(sections) + assert '' in content + assert '
' in content + + +def test_body_background_color_default_matches_todays_value(): + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + _craft_json, content = ec.build_email_content(sections) + assert '' in content + + +def test_section_container_width_gets_no_outer_wrapper_when_unset(): + """Byte-identical-defaults guarantee: a Section that doesn't set + `container_width` gets no outer mso wrapper table at all — matching + this surface's existing "None means no override" convention for every + other layout prop `_section_props` handles.""" + sections = [ + ec.section([ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")]) + ] + craft_json, content = ec.build_email_content(sections) + section_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ) + assert f'class="{eh._section_class(section_id)}"' in content + assert 'role="presentation" align="center" width=' not in content + + +def test_section_container_width_wrapper_carries_the_containerWidth_attribute(): + """Divergence 5 (outer wrapper half): a Section with an explicit + `container_width` gets an mso-conditional table hosting that value as + both a `width` attribute and an inline `width:...px` style — matching + Kizen's own reference-confirmed pattern of applying `containerWidth` to + an outer background-table wrapper (`_render_section`'s simplified + version of it — see the work item's report for the scoping call on how + far this emitter reproduces Kizen's full VML markup).""" + sections = [ + ec.section( + [ec.row([ec.cell([ec.text_block("

x

")])], layout="1 Column")], + container_width="900", + ) + ] + craft_json, content = ec.build_email_content(sections) + section_id = next( + nid for nid, n in craft_json.items() if _resolved_name(n) == "Section" + ) + assert ( + '" + f'
` width alike.""" + sections = [ + ec.section( + [ + ec.row( + [ec.cell([ec.text_block("

x

")])], + layout="1 Column", + container_width="700", + ) + ] + ) + ] + craft_json, content = ec.build_email_content(sections) + row_id = next(nid for nid, n in craft_json.items() if _resolved_name(n) == "Row") + assert _row_style_max_width_px(content, row_id) == "700" + assert _mso_table_width_px(content, row_id) == "700" + assert not re.search(r"\.0px", content) + + +def test_golden_output_for_a_small_synthetic_template(monkeypatch: pytest.MonkeyPatch): + counter = iter(f"{i:024x}" for i in range(1, 50)) + monkeypatch.setattr(form_ui, "_new_id", lambda: next(counter)) + + sections = [ + ec.section( + [ec.row([ec.cell([ec.text_block("

Hello

")])], layout="1 Column")], + background_color="#EEEEEE", + ) + ] + craft_json, content = ec.build_email_content(sections) + + root_id = "ROOT" + section_id = "000000000000000000000001" + row_id = "000000000000000000000002" + cell_id = "000000000000000000000003" + text_id = "000000000000000000000004" + + assert set(craft_json) == {root_id, section_id, row_id, cell_id, text_id} + assert craft_json[root_id]["nodes"] == [section_id] + assert craft_json[section_id]["nodes"] == [row_id] + assert craft_json[row_id]["linkedNodes"] == {"column-1": cell_id} + assert craft_json[cell_id]["props"] == {"__width": 1} + assert craft_json[text_id]["custom"]["text"] == "

Hello

" + + assert f'class="section-{section_id}"' in content + assert f'class="section-{row_id}"' in content + assert f".section-{section_id} {{ background-color:#EEEEEE; }}" in content + assert "

Hello

" in content + assert "mj-column-per-100" in content diff --git a/tests/test_email_craft_upload.py b/tests/test_email_craft_upload.py new file mode 100644 index 0000000..998f7f4 --- /dev/null +++ b/tests/test_email_craft_upload.py @@ -0,0 +1,144 @@ +"""Tests for `tools.email_craft`'s live-call surface: image upload (via +`api.files.upload_file`, `source="public_image"`) and `api.files.delete_file`. + +Everything here is respx-mocked, same three-legged S3 dance +`test_smart_connectors_authoring.py` already exercises for +`SMART_CONNECTOR_IMPORT` — this pins the email-specific `source` constant +and the resulting `Image.src` shape instead. +""" + +from __future__ import annotations + +import struct +import zlib + +import httpx +import pytest +import respx + +from kizen_builder.api import files as files_api +from kizen_builder.api.client import KizenClient +from kizen_builder.models.spec.email_templates import EmailTemplateDef +from kizen_builder.tools import email_craft as ec +from tests.conftest import FAKE_BASE_URL + +S3_URL = "https://files.example.test/" + + +@pytest.fixture +def client(env_config): + with KizenClient(env_config) as c: + yield c + + +def _make_png(width: int, height: int) -> bytes: + sig = b"\x89PNG\r\n\x1a\n" + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + raw = (b"\x00" + b"\xff\x00\x00" * width) * height + idat = zlib.compress(raw) + return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + + +@respx.mock +def test_upload_email_image_uses_public_image_source_and_returns_natural_dims( + client, tmp_path +): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_make_png(12, 34)) + + presign = respx.get(f"{FAKE_BASE_URL}/api/s3/presigned-post").mock( + return_value=httpx.Response( + 200, + json={ + "url": S3_URL, + "fields": {"key": "biz/public_image/logo.png"}, + "s3object_id": "s3-obj-9", + }, + ) + ) + respx.post(S3_URL).mock(return_value=httpx.Response(204, headers={"etag": '"e1"'})) + success = respx.post(f"{FAKE_BASE_URL}/api/s3/success").mock( + return_value=httpx.Response(200, json={"id": "file-9", "name": "logo.png"}) + ) + + info = ec.upload_email_image(client, FAKE_BASE_URL, png_path) + + assert presign.calls.last.request.url.params["source"] == files_api.PUBLIC_IMAGE + # is_public=true is what makes the emitted src reachable by an + # unauthenticated recipient — see api/files.py::upload_file. Without it + # every uploaded image 404s for anyone reading the email outside an + # authenticated session. + assert b"is_public=true" in success.calls.last.request.content + assert info["file_id"] == "file-9" + assert info["src"] == f"{FAKE_BASE_URL}/api/files/file-9/download" + assert info["natural_width"] == 12 + assert info["natural_height"] == 34 + + +@respx.mock +def test_resolve_spec_images_uploads_every_image_block(tmp_path): + png_path = tmp_path / "pic.png" + png_path.write_bytes(_make_png(5, 6)) + respx.get(f"{FAKE_BASE_URL}/api/s3/presigned-post").mock( + return_value=httpx.Response( + 200, + json={"url": S3_URL, "fields": {"key": "k"}, "s3object_id": "s3-obj-1"}, + ) + ) + respx.post(S3_URL).mock(return_value=httpx.Response(204, headers={"etag": '"e"'})) + respx.post(f"{FAKE_BASE_URL}/api/s3/success").mock( + return_value=httpx.Response(200, json={"id": "file-1", "name": "pic.png"}) + ) + + spec = EmailTemplateDef.model_validate( + { + "name": "t", + "sections": [ + { + "rows": [ + { + "layout": "1 Column", + "cells": [ + {"blocks": [{"kind": "image", "file": str(png_path)}]} + ], + } + ] + } + ], + } + ) + resolved = ec.resolve_spec_images(spec) + block = resolved[0]["rows"][0]["cells"][0]["blocks"][0] + assert block["file_id"] == "file-1" + assert block["natural_width"] == 5 + assert block["natural_height"] == 6 + + +@respx.mock +def test_delete_file_is_a_real_delete(client): + route = respx.delete(f"{FAKE_BASE_URL}/api/files/file-1").mock( + return_value=httpx.Response(204) + ) + files_api.delete_file(client, "file-1") + assert route.called + + +@respx.mock +def test_upload_email_image_rejects_unsupported_format_before_any_network_call( + client, tmp_path +): + bogus = tmp_path / "picture.gif" + bogus.write_bytes(b"GIF89a" + b"\x00" * 20) + # No routes registered: any network call would 500 via respx's + # assert_all_mocked default, proving the format check runs first. + with pytest.raises(ValueError, match="GIF"): + ec.upload_email_image(client, FAKE_BASE_URL, bogus) diff --git a/tests/test_email_template_planners.py b/tests/test_email_template_planners.py new file mode 100644 index 0000000..63e01c9 --- /dev/null +++ b/tests/test_email_template_planners.py @@ -0,0 +1,177 @@ +"""Tests for `tools.planners.messages`'s spec-driven email-template planners. + +`plan_create_template_from_spec`/`plan_update_template(..., spec=...)` take +already-resolved sections (image uploads happened earlier, in the CLI layer +— see `tools.email_craft.resolve_spec_images`'s docstring for why that split +exists) and must build a Plan with no live calls of their own beyond what +`plan_update_template` already needs to resolve the target template. Per +`CLAUDE.md`, nothing under `tools/planners/` performs a POST/PUT/PATCH/DELETE +— asserted here indirectly: these tests mock only GET/list endpoints, never +a write, and the planners still succeed. +""" + +from __future__ import annotations + +import httpx +import respx + +from kizen_builder.api.client import KizenClient +from kizen_builder.models.spec.email_templates import EmailTemplateDef +from kizen_builder.tools.messages import craft_summary +from kizen_builder.tools.planners import messages as message_planners +from kizen_builder.tools.plans import PlanError +from tests.conftest import FAKE_BASE_URL + +TEMPLATE_ID = "7cb5ce29-bf20-4f0f-bdc9-412a8c777ff8" + + +def _resolved_sections(*, layout: str = "1 Column", n_cells: int = 1) -> list[dict]: + cells = [ + {"blocks": [{"kind": "text", "html": f"

cell {i}

"}]} + for i in range(n_cells) + ] + return [ + {"background_color": "#FFFFFF", "rows": [{"layout": layout, "cells": cells}]} + ] + + +def _spec(**overrides) -> EmailTemplateDef: + base = {"name": "Newsletter", "subject": "Hi", "sections": []} + base.update(overrides) + return EmailTemplateDef.model_validate(base) + + +# --------------------------------------------------------------------------- +# plan_create_template_from_spec +# --------------------------------------------------------------------------- + + +def test_plan_create_builds_one_create_op_with_coupled_content(): + spec = _spec() + plan = message_planners.plan_create_template_from_spec(spec, _resolved_sections()) + assert len(plan.operations) == 1 + op = plan.operations[0] + assert op.action == "create" + assert op.kind == "email_template" + assert op.key == "Newsletter" + assert op.payload["name"] == "Newsletter" + assert op.payload["subject"] == "Hi" + assert op.payload["type"] == "email" + assert op.payload["sender_type"] == "business" + assert op.payload["from_name_type"] == "default" + # No raw craft_json/content ever entered this function — both are + # derived from resolved_sections by the same one-pass emitter. + summary = craft_summary( + {"craft_json": op.payload["craft_json"], "content": op.payload["content"]} + ) + assert summary["coupled"] is True + + +@respx.mock +def test_plan_create_makes_no_live_calls(): + """A planner performs no POST/PUT/PATCH/DELETE (CLAUDE.md). No route is + registered here, so respx's default `assert_all_mocked` would raise on + any httpx call this function tries to make — its absence is the proof.""" + message_planners.plan_create_template_from_spec(_spec(), _resolved_sections()) + + +def test_plan_create_row_cell_count_mismatch_raises_plan_error_not_silent_reshape(): + resolved = [ + { + "background_color": "#FFFFFF", + "rows": [ + { + "layout": "2 Columns", + "cells": [{"blocks": []}], # needs 2, got 1 + } + ], + } + ] + try: + message_planners.plan_create_template_from_spec(_spec(), resolved) + raise AssertionError("expected PlanError") + except PlanError as e: + assert "2 cell" in str(e) + + +def test_plan_create_unsupported_block_kind_raises_plan_error(): + resolved = [ + { + "background_color": "#FFFFFF", + "rows": [ + {"layout": "1 Column", "cells": [{"blocks": [{"kind": "attachments"}]}]} + ], + } + ] + try: + message_planners.plan_create_template_from_spec(_spec(), resolved) + raise AssertionError("expected PlanError") + except PlanError as e: + assert "unsupported block kind" in str(e) + + +# --------------------------------------------------------------------------- +# plan_update_template(..., spec=..., resolved_sections=...) +# --------------------------------------------------------------------------- + + +def _client() -> KizenClient: + from kizen_builder.config import load_env_config + + return KizenClient(load_env_config()) + + +@respx.mock +def test_plan_update_from_spec_rebuilds_both_fields_together(): + existing = { + "id": TEMPLATE_ID, + "name": "Old Name", + "subject": "Old subject", + "craft_json": {"ROOT": {"type": {"resolvedName": "Root"}}}, + "content": "

old

", + } + respx.get(f"{FAKE_BASE_URL}/api/messages/templates/{TEMPLATE_ID}").mock( + return_value=httpx.Response(200, json=existing) + ) + spec = _spec(name="New Name", subject="New subject") + plan = message_planners.plan_update_template( + TEMPLATE_ID, spec=spec, resolved_sections=_resolved_sections() + ) + op = plan.operations[0] + assert op.action == "update" + assert op.existing_uuid == TEMPLATE_ID + assert op.payload["name"] == "New Name" + assert op.payload["subject"] == "New subject" + summary = craft_summary( + {"craft_json": op.payload["craft_json"], "content": op.payload["content"]} + ) + assert summary["coupled"] is True + + +def test_plan_update_raw_patch_path_is_unchanged(): + """The existing --craft-json-file/--content-file path (patch dict, no + spec) must keep working exactly as before this item.""" + with respx.mock: + respx.get(f"{FAKE_BASE_URL}/api/messages/templates/{TEMPLATE_ID}").mock( + return_value=httpx.Response( + 200, + json={"id": TEMPLATE_ID, "name": "t", "craft_json": {}, "content": ""}, + ) + ) + plan = message_planners.plan_update_template(TEMPLATE_ID, {"name": "renamed"}) + assert plan.operations[0].payload == {"name": "renamed"} + + +def test_plan_update_with_neither_patch_nor_spec_raises_plan_error(): + with respx.mock: + respx.get(f"{FAKE_BASE_URL}/api/messages/templates/{TEMPLATE_ID}").mock( + return_value=httpx.Response( + 200, + json={"id": TEMPLATE_ID, "name": "t", "craft_json": {}, "content": ""}, + ) + ) + try: + message_planners.plan_update_template(TEMPLATE_ID, {}) + raise AssertionError("expected PlanError") + except PlanError as e: + assert "nothing to update" in str(e) diff --git a/tests/test_email_template_spec.py b/tests/test_email_template_spec.py new file mode 100644 index 0000000..0d93144 --- /dev/null +++ b/tests/test_email_template_spec.py @@ -0,0 +1,233 @@ +"""Tests for `models.spec.email_templates.EmailTemplateDef` and friends. + +Pins the acceptance criteria the model alone is responsible for: the row +layout is a closed enum (an invalid preset is unrepresentable, not just a +validation error to catch after the fact), and `create`'s spec has no way to +smuggle a raw `craft_json`/`content` value past validation. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from kizen_builder.models.spec.email_templates import ( + ButtonBlockDef, + DividerBlockDef, + EmailTemplateDef, + ImageBlockDef, + PaddingDef, + RowDef, + SectionDef, +) + + +def _spec(**overrides): + base = { + "name": "Newsletter", + "subject": "Hello", + "sections": [ + { + "rows": [ + { + "layout": "1 Column", + "cells": [{"blocks": [{"kind": "text", "html": "

hi

"}]}], + } + ] + } + ], + } + base.update(overrides) + return base + + +def test_minimal_valid_spec(): + spec = EmailTemplateDef.model_validate(_spec()) + assert spec.name == "Newsletter" + assert spec.sections[0].rows[0].layout == "1 Column" + + +def test_subject_defaults_to_empty_string(): + spec = EmailTemplateDef.model_validate({"name": "t", "sections": []}) + assert spec.subject == "" + + +@pytest.mark.parametrize( + "layout", + [ + "1 Column", + "2 Columns", + "2 Columns (1/3 and 2/3)", + "2 Columns (2/3 and 1/3)", + ], +) +def test_v1_layout_names_are_accepted(layout): + spec_dict = _spec() + spec_dict["sections"][0]["rows"][0]["layout"] = layout + EmailTemplateDef.model_validate(spec_dict) # must not raise + + +@pytest.mark.parametrize( + "layout", ["3 Columns", "4 Columns", "Two Columns", "50/50", "1column"] +) +def test_invalid_or_out_of_scope_layout_names_are_unrepresentable(layout): + spec_dict = _spec() + spec_dict["sections"][0]["rows"][0]["layout"] = layout + with pytest.raises(ValidationError): + EmailTemplateDef.model_validate(spec_dict) + + +@pytest.mark.parametrize("kind", ["text", "image", "button", "divider"]) +def test_v1_block_kinds_are_accepted(kind): + block = { + "text": {"kind": "text", "html": "

hi

"}, + "image": {"kind": "image", "file": "/tmp/x.png"}, + "button": {"kind": "button", "label": "Go", "url": "https://x"}, + "divider": {"kind": "divider"}, + }[kind] + spec_dict = _spec() + spec_dict["sections"][0]["rows"][0]["cells"][0]["blocks"] = [block] + EmailTemplateDef.model_validate(spec_dict) # must not raise + + +@pytest.mark.parametrize("kind", ["attachments", "html", "custom_field", "video"]) +def test_unsupported_block_kinds_are_unrepresentable(kind): + spec_dict = _spec() + spec_dict["sections"][0]["rows"][0]["cells"][0]["blocks"] = [{"kind": kind}] + with pytest.raises(ValidationError): + EmailTemplateDef.model_validate(spec_dict) + + +def test_no_raw_craft_json_key_anywhere_in_the_model(): + """The foot-gun this whole surface exists to close: create must not + accept a hand-authored craft_json that can drift from `content`.""" + with pytest.raises(ValidationError): + EmailTemplateDef.model_validate(_spec(craft_json={})) + + +def test_no_raw_content_key_anywhere_in_the_model(): + with pytest.raises(ValidationError): + EmailTemplateDef.model_validate(_spec(content="

hand-authored

")) + + +def test_no_sender_type_key_in_the_model(): + """sender_type is hard-coded "business" by the planner — not a spec key.""" + with pytest.raises(ValidationError): + EmailTemplateDef.model_validate(_spec(sender_type="business")) + + +def test_grep_confirms_neither_craft_json_nor_content_is_a_model_field(): + field_names = set(EmailTemplateDef.model_fields) + assert "craft_json" not in field_names + assert "content" not in field_names + + +# --------------------------------------------------------------------------- +# Layout props (BCLI-024) — defaults reproduce today's hardcoded emitter +# output; explicit values round-trip through the model unchanged. +# --------------------------------------------------------------------------- + + +def test_padding_def_defaults_to_uniform_10_on_all_four_sides(): + p = PaddingDef() + assert (p.top, p.right, p.bottom, p.left) == ("10", "10", "10", "10") + + +def test_padding_def_sides_are_independently_settable(): + p = PaddingDef.model_validate( + {"top": "10", "right": "40", "bottom": "10", "left": "40"} + ) + assert (p.top, p.right, p.bottom, p.left) == ("10", "40", "10", "40") + + +def test_section_def_layout_defaults_reproduce_todays_hardcoded_emitter_output(): + """`max_width` defaults to `900` (today's `form_ui._assemble_section` + hardcode), not the reference template's `600` — see this item's + Implementation notes for why the acceptance criteria's literal default + was corrected. `container_width`/`padding` default to `None`, meaning + "no override", matching that `containerWidth` is absent and padding is + the uniform `10` today.""" + s = SectionDef.model_validate({"rows": []}) + assert s.max_width == "900" + assert s.container_width is None + assert s.padding is None + + +def test_section_def_layout_props_are_independently_settable(): + s = SectionDef.model_validate( + { + "rows": [], + "max_width": "600", + "container_width": "900", + "padding": {"top": "0", "right": "0", "bottom": "0", "left": "0"}, + } + ) + assert s.max_width == "600" + assert s.container_width == "900" + assert s.padding == PaddingDef(top="0", right="0", bottom="0", left="0") + + +def test_row_def_layout_defaults_reproduce_todays_hardcoded_emitter_output(): + r = RowDef.model_validate({"cells": []}) + assert r.width == "100" + assert r.container_width is None + assert r.padding is None + + +def test_row_def_layout_props_are_independently_settable(): + """`Row.width`/`container_width`/`padding` are not derived from the + parent `Section` — the reference template shows them varying + row-to-row with no clean formula (BCLI-024 Context).""" + r = RowDef.model_validate( + { + "cells": [], + "width": "75", + "container_width": "580", + "padding": {"top": "10", "right": "40", "bottom": "10", "left": "40"}, + } + ) + assert r.width == "75" + assert r.container_width == "580" + assert r.padding == PaddingDef(top="10", right="40", bottom="10", left="40") + + +def test_divider_block_def_size_defaults_to_todays_hardcoded_3(): + d = DividerBlockDef() + assert d.size == "3" + + +def test_button_block_def_layout_props_default_to_todays_hardcoded_values(): + b = ButtonBlockDef(label="Go", url="https://x") + assert (b.border_radius, b.padding_left, b.padding_right, b.alignment) == ( + "8", + "20", + "20", + "center", + ) + + +@pytest.mark.parametrize("alignment", ["left", "center", "right"]) +def test_button_block_def_alignment_accepts_the_closed_enum(alignment): + b = ButtonBlockDef(label="Go", url="https://x", alignment=alignment) + assert b.alignment == alignment + + +def test_button_block_def_alignment_rejects_values_outside_the_closed_enum(): + with pytest.raises(ValidationError): + ButtonBlockDef(label="Go", url="https://x", alignment="justify") + + +def test_image_block_def_layout_props_default_to_none_matching_absent_keys_today(): + img = ImageBlockDef(file="/tmp/x.png") + assert img.container_width is None + assert img.max_width is None + assert img.max_height is None + + +def test_image_block_def_layout_props_are_independently_settable(): + img = ImageBlockDef( + file="/tmp/x.png", container_width="580", max_width="300", max_height="200" + ) + assert img.container_width == "580" + assert img.max_width == "300" + assert img.max_height == "200" diff --git a/tests/test_smart_connectors_authoring.py b/tests/test_smart_connectors_authoring.py index 4061a27..8b4ed3a 100644 --- a/tests/test_smart_connectors_authoring.py +++ b/tests/test_smart_connectors_authoring.py @@ -184,6 +184,29 @@ def test_upload_file_walks_presign_s3_and_success(client, tmp_path): ) assert b"uuid=s3-obj-1" in ok_req.content assert b"etag=abc123" in ok_req.content + # is_public defaults to omitted (server default is false) — a smart + # connector's reference file has no reason to be world-readable. + assert b"is_public" not in ok_req.content + + +@respx.mock +def test_upload_file_sends_is_public_true_when_requested(client, tmp_path): + src = tmp_path / "sample.csv" + src.write_bytes(b"order_number\n1\n") + respx.get(f"{FAKE_BASE_URL}/api/s3/presigned-post").mock( + return_value=httpx.Response( + 200, + json={"url": S3_URL, "fields": {"key": "k"}, "s3object_id": "s3-obj-1"}, + ) + ) + respx.post(S3_URL).mock(return_value=httpx.Response(204, headers={"etag": '"e"'})) + success = respx.post(f"{FAKE_BASE_URL}/api/s3/success").mock( + return_value=httpx.Response(200, json={"id": "file-1", "name": "sample.csv"}) + ) + + files_api.upload_file(client, src, is_public=True) + + assert b"is_public=true" in success.calls.last.request.content @respx.mock