*/ private array $buttons; /** * §8.2 `badges` slot, list-shaped. * * @var list */ private array $badges; /** * §8.2 `disclaimers` slot, list-shaped. * * @var list */ private array $disclaimers; /** * §8.2 `meta` slot. */ private MetaFlags $meta; /** * §8.2 `classes` slot. */ private CssClasses $classes; /** * Construct a product card view model with explicit values * for every field. * * The constructor is exhaustive so a programming error * (forgetting to compose a slot, swapping `buttons` / * `badges`) surfaces at the type system boundary rather than * at the renderer. Named-argument construction is the * ergonomic entry point for most call sites; the * {@see self::create()} factory covers the common case of * "type defaults to the §8.2 discriminator". * * Validation summary: * * - `type` is trimmed and matched against {@see self::TYPE}; * out-of-set tokens raise {@see InvalidArgumentException} * carrying the offending value verbatim. * - `template` is trimmed and rejected when empty after * trimming. Format validation lives at the TemplateResolver. * - `buttons`, `badges`, `disclaimers` MUST be zero-based * contiguous lists. Empty lists are accepted; non-list * shapes (associative arrays, sparse keys) and wrong-typed * entries are rejected. Disclaimer strings are trimmed and * rejected when empty after trimming. * * @param string $type §8.2 discriminator string. * @param string $template §6.4 template key. * @param ProductInfoView $product §8.2 `product` slot. * @param ImageSet $images §8.2 `images` slot. * @param PriceView $price §8.2 `price` slot. * @param AvailabilityView $availability §8.2 `availability` slot. * @param list $buttons §8.2 `buttons` slot. * @param list $badges §8.2 `badges` slot. * @param list $disclaimers §8.2 `disclaimers` slot. * @param MetaFlags $meta §8.2 `meta` slot. * @param CssClasses $classes §8.2 `classes` slot. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $type, string $template, ProductInfoView $product, ImageSet $images, PriceView $price, AvailabilityView $availability, array $buttons, array $badges, array $disclaimers, MetaFlags $meta, CssClasses $classes ) { $this->type = self::normalizeType( $type ); $this->template = self::normalizeTemplate( $template ); $this->product = $product; $this->images = $images; $this->price = $price; $this->availability = $availability; $this->buttons = self::normalizeButtonList( $buttons ); $this->badges = self::normalizeBadgeList( $badges ); $this->disclaimers = self::normalizeDisclaimerList( $disclaimers ); $this->meta = $meta; $this->classes = $classes; } /** * Construct a product card view model with the §8.2 * discriminator string defaulted. * * Convenience factory so call sites do not have to spell out * the fixed `type` argument; mirrors the parameter order used * by the ViewModel factory at the §9.1 boundary. * * @param ProductInfoView $product §8.2 `product` slot. * @param ImageSet $images §8.2 `images` slot. * @param PriceView $price §8.2 `price` slot. * @param AvailabilityView $availability §8.2 `availability` slot. * @param MetaFlags $meta §8.2 `meta` slot. * @param CssClasses $classes §8.2 `classes` slot. * @param string $template §6.4 template key. * @param list $buttons §8.2 `buttons` slot. * @param list $badges §8.2 `badges` slot. * @param list $disclaimers §8.2 `disclaimers` slot. * * @throws InvalidArgumentException When any field violates the * per-field rules documented * on the constructor. */ public static function create( ProductInfoView $product, ImageSet $images, PriceView $price, AvailabilityView $availability, MetaFlags $meta, CssClasses $classes, string $template, array $buttons = array(), array $badges = array(), array $disclaimers = array() ): self { return new self( self::TYPE, $template, $product, $images, $price, $availability, $buttons, $badges, $disclaimers, $meta, $classes ); } /** * §8.2 fixed `type` discriminator string. */ public function type(): string { return $this->type; } /** * §6.4 template key. */ public function template(): string { return $this->template; } /** * §8.2 `product` slot. */ public function product(): ProductInfoView { return $this->product; } /** * §8.2 `images` slot. */ public function images(): ImageSet { return $this->images; } /** * §8.2 `price` slot. */ public function price(): PriceView { return $this->price; } /** * §8.2 `availability` slot. */ public function availability(): AvailabilityView { return $this->availability; } /** * §8.2 `buttons` slot. * * @return list */ public function buttons(): array { return $this->buttons; } /** * §8.2 `badges` slot. * * @return list */ public function badges(): array { return $this->badges; } /** * §8.2 `disclaimers` slot. * * @return list */ public function disclaimers(): array { return $this->disclaimers; } /** * §8.2 `meta` slot. */ public function meta(): MetaFlags { return $this->meta; } /** * §8.2 `classes` slot. */ public function classes(): CssClasses { return $this->classes; } /** * Report whether this aggregator is the §8.4 fallback variant. * * Always false for {@see ProductCardViewModel}; the * {@see FallbackCardViewModel} sibling returns true. Used by * the renderer's polymorphic dispatch to short-circuit the * full-card pipeline when a fallback payload arrives. */ public function isFallback(): bool { return false; } /** * Produce a copy of this view model with `buttons` replaced. * * @param list $buttons Replacement button list. */ public function withButtons( array $buttons ): self { $clone = clone $this; $clone->buttons = self::normalizeButtonList( $buttons ); return $clone; } /** * Produce a copy of this view model with `badges` replaced. * * @param list $badges Replacement badge list. */ public function withBadges( array $badges ): self { $clone = clone $this; $clone->badges = self::normalizeBadgeList( $badges ); return $clone; } /** * Produce a copy of this view model with `template` replaced. * * Used by the ViewModel factory when it picks a more specific * template variant after the rest of the payload has already * been composed. Other fields are preserved verbatim. * * @param string $template New §6.4 template key. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ public function withTemplate( string $template ): self { $clone = clone $this; $clone->template = self::normalizeTemplate( $template ); return $clone; } /** * Report whether this view model equals the supplied one. * * Equality is structural: every field must match with strict * comparison, every VO part must compare equal under its own * `equals()`, and every list must match element-by-element. * * @param self $other Other view model to compare against. */ public function equals( self $other ): bool { if ( $this->type !== $other->type ) { return false; } if ( $this->template !== $other->template ) { return false; } if ( ! $this->product->equals( $other->product ) ) { return false; } if ( ! $this->images->equals( $other->images ) ) { return false; } if ( ! $this->price->equals( $other->price ) ) { return false; } if ( ! $this->availability->equals( $other->availability ) ) { return false; } if ( count( $this->buttons ) !== count( $other->buttons ) ) { return false; } foreach ( $this->buttons as $index => $button ) { if ( ! $button->equals( $other->buttons[ $index ] ) ) { return false; } } if ( count( $this->badges ) !== count( $other->badges ) ) { return false; } foreach ( $this->badges as $index => $badge ) { if ( ! $badge->equals( $other->badges[ $index ] ) ) { return false; } } if ( $this->disclaimers !== $other->disclaimers ) { return false; } if ( ! $this->meta->equals( $other->meta ) ) { return false; } if ( ! $this->classes->equals( $other->classes ) ) { return false; } return true; } /** * Validate and normalize the `type` field at construction. * * The token is trimmed first so a carelessly-decoded payload * is normalized to the same canonical form a freshly- * constructed token would carry. The trimmed value MUST equal * {@see self::TYPE}; anything else raises * {@see InvalidArgumentException}. * * @param string $type Raw discriminator as supplied by the caller. * * @throws InvalidArgumentException When `$type` is not the * §8.2 discriminator string. */ private static function normalizeType( string $type ): string { $trimmed = trim( $type ); if ( $trimmed !== self::TYPE ) { $message = sprintf( 'ProductCardViewModel type must be "%s"; got "%s".', self::TYPE, $type ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `template` field at construction. * * Trims surrounding whitespace and rejects the result when it * is the empty string. The TemplateResolver enforces the * `^[a-z][a-z0-9-]{1,63}$` grammar at the resolution boundary. * * @param string $template Raw template key as supplied by the caller. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ private static function normalizeTemplate( string $template ): string { $trimmed = trim( $template ); if ( $trimmed === '' ) { $message = 'ProductCardViewModel template must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `buttons` field at construction. * * Accepts a zero-based contiguous list of {@see ButtonView} * instances. Non-list shapes (associative arrays, sparse keys) * and non-{@see ButtonView} entries are rejected. The empty * list is accepted per §13.3 "ボタンなし" graceful degradation. * * @param array $buttons Raw buttons list. * * @return list * * @throws InvalidArgumentException When `$buttons` violates * the rules above. */ private static function normalizeButtonList( array $buttons ): array { $expected = 0; $result = array(); foreach ( $buttons as $key => $value ) { if ( $key !== $expected ) { $message = 'ProductCardViewModel buttons must be a zero-based contiguous list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $value instanceof ButtonView ) { $message = sprintf( 'ProductCardViewModel buttons entries must be ButtonView instances; got %s at index %d.', is_object( $value ) ? get_class( $value ) : gettype( $value ), $expected ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result[] = $value; ++$expected; } return $result; } /** * Validate and normalize the `badges` field at construction. * * Accepts a zero-based contiguous list of {@see BadgeView} * instances. Non-list shapes (associative arrays, sparse keys) * and non-{@see BadgeView} entries are rejected. The empty * list is accepted. * * @param array $badges Raw badges list. * * @return list * * @throws InvalidArgumentException When `$badges` violates * the rules above. */ private static function normalizeBadgeList( array $badges ): array { $expected = 0; $result = array(); foreach ( $badges as $key => $value ) { if ( $key !== $expected ) { $message = 'ProductCardViewModel badges must be a zero-based contiguous list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $value instanceof BadgeView ) { $message = sprintf( 'ProductCardViewModel badges entries must be BadgeView instances; got %s at index %d.', is_object( $value ) ? get_class( $value ) : gettype( $value ), $expected ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result[] = $value; ++$expected; } return $result; } /** * Validate and normalize the `disclaimers` field at * construction. * * Accepts a zero-based contiguous list of non-empty strings. * Non-list shapes (associative arrays, sparse keys), non-string * entries, and empty / whitespace-only strings are rejected. * The empty list is accepted. * * @param array $disclaimers Raw disclaimers list. * * @return list * * @throws InvalidArgumentException When `$disclaimers` violates * the rules above. */ private static function normalizeDisclaimerList( array $disclaimers ): array { $expected = 0; $result = array(); foreach ( $disclaimers as $key => $value ) { if ( $key !== $expected ) { $message = 'ProductCardViewModel disclaimers must be a zero-based contiguous list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_string( $value ) ) { $message = sprintf( 'ProductCardViewModel disclaimers entries must be strings; got %s at index %d.', is_object( $value ) ? get_class( $value ) : gettype( $value ), $expected ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ProductCardViewModel disclaimers entries must be non-empty strings; got empty entry at index %d.', $expected ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result[] = $trimmed; ++$expected; } return $result; } }