diff --git a/.changeset/dooh-placement-attributes.md b/.changeset/dooh-placement-attributes.md new file mode 100644 index 0000000000..bd8fc01f41 --- /dev/null +++ b/.changeset/dooh-placement-attributes.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add DOOH structured selling-unit fields to placements: `dooh_placement_attributes` (slot_duration_seconds, loop_duration_seconds, screen_resolution, motion) and `identifiers[]` on both placement.json and placement-definition.json. Define deterministic publisher/product inheritance, post-merge slot-to-loop validation, versioned OpenOOH identifiers, and canonical-format authority. Add the `dooh-motion-type` enum and supersede pricing-layer loop_duration_seconds in flat-rate-option.json. diff --git a/docs/media-buy/advanced-topics/pricing-models.mdx b/docs/media-buy/advanced-topics/pricing-models.mdx index 16bfea69f4..8d35eb6ecc 100644 --- a/docs/media-buy/advanced-topics/pricing-models.mdx +++ b/docs/media-buy/advanced-topics/pricing-models.mdx @@ -456,7 +456,6 @@ Revenue share is channel-independent. Affiliate is the immediate use case, but s "daypart": "morning_commute", "duration_hours": 4, "sov_percentage": 25, - "loop_duration_seconds": 15, "estimated_impressions": 120000 } } @@ -466,7 +465,7 @@ Revenue share is channel-independent. Affiliate is the immediate use case, but s **DOOH parameters** (`parameters.type: "dooh"`): - `sov_percentage`: Guaranteed share of voice as a percentage (0-100) -- `loop_duration_seconds`: Duration of ad loop rotation in seconds +- `loop_duration_seconds`: Deprecated compatibility copy of the placement's `dooh_placement_attributes.loop_duration_seconds`. New integrations read the placement-level value; when both are present they must agree. Offers with different loop durations use distinct placements or products rather than pricing-option-specific copies. - `min_plays_per_hour`: Minimum guaranteed plays per hour - `venue_package`: Named collection of screens - `duration_hours`: Duration of the slot in hours (e.g., 24 for a full-day takeover) diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index f829c76327..6083360461 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -337,6 +337,25 @@ The field is an array because a sellable product can aggregate multiple surfaces This is a discovery signal, not a verification claim. Buyers can filter for products that can satisfy a requested surface with `get_products.filters.social_placement_surfaces`, but sellers should not return mixed, non-targetable bundles unless they can constrain delivery to the requested surface during planning or purchase. +#### DOOH placement attributes + +Digital out-of-home placements can declare structured selling-unit metadata via `dooh_placement_attributes` at the placement level (both in product placements and in `adagents.json` placement definitions). These fields describe the physical screen and ad-loop characteristics that buyers need for creative production and share-of-voice calculations: + +| Field | Type | Meaning | +|---|---|---| +| `slot_duration_seconds` | integer | Duration of one ad slot in seconds (e.g., 10, 15, 30) | +| `loop_duration_seconds` | integer | Duration of the full ad loop rotation in seconds. Buyers derive nominal slot share as `slot_duration_seconds / loop_duration_seconds`. This is the canonical source; the pricing-layer field in `flat-rate-option` is superseded | +| `screen_resolution` | object | Physical screen resolution (`{ width, height }` in pixels). Buyers derive aspect ratio from `width / height` | +| `motion` | string | Motion capability of the screen: `full_motion` (video), `partial_motion` (animated stills), or `static` (images only) | + +All fields are optional. A single screen/frame placement can include all fields; a package or network placement should include only fields that are uniform across the included inventory. After publisher and product declarations are resolved, `slot_duration_seconds` MUST NOT exceed `loop_duration_seconds`. + +These are inventory facts, not an alternative creative contract. Effective canonical `format_options` remains authoritative for accepted dimensions, durations, asset types, and codecs. A physical screen can therefore report a larger `screen_resolution` than the content region declared by its formats, and a full-motion screen can still offer a static-only product. + +For `kind: "publisher_ref"`, resolve the publisher placement before applying product detail. Product-level `slot_duration_seconds` and `loop_duration_seconds` override publisher defaults for that specific offer; omitted values inherit. `screen_resolution` and `motion` are intrinsic publisher facts, so repeated product values MUST match the publisher declaration. Treat mismatches and an invalid effective slot-to-loop ratio as conformance errors rather than silently choosing one source. + +Placements can also carry `identifiers[]` using the same `{ type, value }` shape as property identifiers. DOOH placements commonly use `venue_id`, `screen_id`, and `openooh_venue_type` identifier types. Externally governed IDs should be authority-prefixed (e.g., `geopath:30961`). OpenOOH classifications include the taxonomy version (`openooh-1.1:20501`), since a bare category number does not identify which revision defined it. For publisher references, union publisher and product identifiers by exact `(type, value)`; product omission does not remove a publisher identifier. + #### Format precedence with placements Product-level `format_options` define the creative formats accepted by the product as a whole. Placement-level `format_options`, whether returned inline on the product placement or inherited from a public publisher placement declaration, only narrow that product-wide set for the specific placement. Deprecated `format_ids` may appear only as a 3.x compatibility projection of the same declarations. diff --git a/static/schemas/source/core/canonical-placement.json b/static/schemas/source/core/canonical-placement.json index 3ccfd0fe98..c4a3660232 100644 --- a/static/schemas/source/core/canonical-placement.json +++ b/static/schemas/source/core/canonical-placement.json @@ -20,7 +20,70 @@ "video_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/video-placement-type.json" }, "minItems": 1, "uniqueItems": true }, "audio_distribution_types": { "type": "array", "items": { "$ref": "/schemas/enums/audio-distribution-type.json" }, "minItems": 1, "uniqueItems": true }, "sponsored_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/sponsored-placement-type.json" }, "minItems": 1, "uniqueItems": true }, - "social_placement_surfaces": { "type": "array", "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, "minItems": 1, "uniqueItems": true } + "social_placement_surfaces": { "type": "array", "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, "minItems": 1, "uniqueItems": true }, + "identifiers": { + "type": "array", + "description": "Optional external inventory identifiers for this placement. Externally governed values should be authority-prefixed; seller-local values are scoped by the surrounding publisher namespace. For publisher_ref placements, the effective set is the union of publisher and product declarations, de-duplicated by exact (type, value).", + "items": { + "type": "object", + "properties": { + "type": { "$ref": "/schemas/enums/identifier-types.json" }, + "value": { "type": "string" } + }, + "required": ["type", "value"], + "additionalProperties": true + }, + "uniqueItems": true, + "minItems": 1 + }, + "dooh_placement_attributes": { + "type": "object", + "description": "DOOH screen and scheduled-loop facts. These fields do not define creative acceptance, which is governed exclusively by effective canonical format_options. For publisher_ref placements, product slot and loop values override publisher defaults while repeated screen_resolution and motion values must equal the publisher facts.", + "properties": { + "slot_duration_seconds": { + "type": "integer", + "minimum": 1, + "description": "Scheduled duration of one ad slot in seconds; not the creative-duration contract." + }, + "loop_duration_seconds": { + "type": "integer", + "minimum": 1, + "description": "Duration of the full ad loop rotation in seconds and the canonical source for loop duration." + }, + "screen_resolution": { + "type": "object", + "description": "Physical screen resolution; canonical format dimensions remain authoritative for creative acceptance.", + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "motion": { + "$ref": "/schemas/enums/dooh-motion-type.json", + "description": "Physical motion capability, not an accepted-format declaration." + } + }, + "x-adcp-validation": { + "verifier_constraints": { + "slot_fits_loop": { + "left_path": "slot_duration_seconds", + "operator": "less_than_or_equal", + "right_path": "loop_duration_seconds", + "evaluate_after": "publisher_ref_resolution" + }, + "publisher_ref_resolution": { + "override_fields": ["slot_duration_seconds", "loop_duration_seconds"], + "inherit_when_omitted": true, + "must_equal_fields": ["screen_resolution", "motion"], + "identifier_merge": "union_by_type_and_value" + } + }, + "spec": "docs/media-buy/product-discovery/media-products.mdx#dooh-placement-attributes" + }, + "additionalProperties": true + } }, "required": ["kind", "placement_id", "mode"], "allOf": [ diff --git a/static/schemas/source/core/placement-definition.json b/static/schemas/source/core/placement-definition.json index 8e8032b0c4..e83f010176 100644 --- a/static/schemas/source/core/placement-definition.json +++ b/static/schemas/source/core/placement-definition.json @@ -139,6 +139,75 @@ "uniqueItems": true, "minItems": 1 }, + "identifiers": { + "type": "array", + "description": "Optional external inventory identifiers for this placement, using the same {type, value} shape as property identifiers. Externally governed IDs should be authority-prefixed (e.g., space:1234931339, geopath:30961, fcc:73953). Seller-local IDs are opaque values scoped by the surrounding publisher namespace. Product-level placement declarations may carry additional identifiers but SHOULD NOT contradict publisher-declared identifiers for the same type.", + "items": { + "type": "object", + "properties": { + "type": { + "$ref": "/schemas/enums/identifier-types.json" + }, + "value": { + "type": "string", + "description": "Identifier value, optionally authority-prefixed for externally governed IDs (e.g., space:1234931339)." + } + }, + "required": ["type", "value"], + "additionalProperties": true + }, + "uniqueItems": true, + "minItems": 1 + }, + "dooh_placement_attributes": { + "type": "object", + "description": "Publisher-declared DOOH inventory facts for digital out-of-home placements. These fields describe the screen and default scheduled loop; they do not define creative acceptance, which is governed exclusively by effective canonical format_options. Each field is optional and should only be populated when it is true for the placement being represented. A single screen/frame placement can include all fields; a package/network placement should include fields only when they are uniform across the included inventory. Referencing products may override slot_duration_seconds and loop_duration_seconds for a specific offer. screen_resolution and motion are intrinsic publisher facts and MUST NOT be changed by a referencing product.", + "properties": { + "slot_duration_seconds": { + "type": "integer", + "description": "Default scheduled duration of one ad slot in seconds (e.g., 10, 15, 30). This is an inventory fact used for loop and share calculations, not the creative-duration contract; format_options remains authoritative for accepted creative durations.", + "minimum": 1 + }, + "loop_duration_seconds": { + "type": "integer", + "description": "Duration of the full ad loop rotation in seconds. Buyers can derive nominal slot share as slot_duration_seconds / loop_duration_seconds. This is the canonical source for loop duration; the pricing-layer field in flat-rate-option.json is superseded by this placement-level declaration.", + "minimum": 1 + }, + "screen_resolution": { + "type": "object", + "description": "Physical screen resolution in pixels. Buyers can derive aspect ratio from width/height. This does not replace canonical format dimensions, which remain authoritative for creative acceptance and may describe a smaller content region within the physical screen.", + "properties": { + "width": { + "type": "integer", + "description": "Screen width in pixels.", + "minimum": 1 + }, + "height": { + "type": "integer", + "description": "Screen height in pixels.", + "minimum": 1 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "motion": { + "$ref": "/schemas/enums/dooh-motion-type.json", + "description": "Physical motion capability of the DOOH screen. This is discovery metadata, not an accepted-format declaration; effective format_options determines whether a particular full-motion, partial-motion, or static creative is accepted." + } + }, + "x-adcp-validation": { + "verifier_constraints": { + "slot_fits_loop": { + "left_path": "slot_duration_seconds", + "operator": "less_than_or_equal", + "right_path": "loop_duration_seconds" + } + }, + "spec": "docs/media-buy/product-discovery/media-products.mdx#dooh-placement-attributes" + }, + "additionalProperties": true + }, "ext": { "$ref": "/schemas/core/ext.json" } diff --git a/static/schemas/source/core/placement.json b/static/schemas/source/core/placement.json index a44bf3d8d4..e37b04fd2a 100644 --- a/static/schemas/source/core/placement.json +++ b/static/schemas/source/core/placement.json @@ -93,6 +93,82 @@ }, "uniqueItems": true, "minItems": 1 + }, + "identifiers": { + "type": "array", + "description": "Optional external inventory identifiers for this placement, using the same {type, value} shape as property identifiers. Externally governed IDs should be authority-prefixed (e.g., space:1234931339, geopath:30961, fcc:73953). Seller-local IDs are opaque values scoped by the surrounding publisher namespace. Useful for DOOH screen/venue IDs, broadcast facility IDs, and any channel where placements map to externally registered inventory. For kind: publisher_ref, the effective identifier set is the union of the resolved publisher declaration and this product declaration, de-duplicated by exact (type, value); a product cannot suppress a publisher-declared identifier by omission.", + "items": { + "type": "object", + "properties": { + "type": { + "$ref": "/schemas/enums/identifier-types.json" + }, + "value": { + "type": "string", + "description": "Identifier value, optionally authority-prefixed for externally governed IDs (e.g., space:1234931339)." + } + }, + "required": ["type", "value"], + "additionalProperties": true + }, + "uniqueItems": true, + "minItems": 1 + }, + "dooh_placement_attributes": { + "type": "object", + "description": "DOOH-specific inventory facts for digital out-of-home placements. These fields describe the screen and scheduled loop; they do not define creative acceptance, which is governed exclusively by the placement's effective canonical format_options. Each field is optional and should only be populated when it is true for the placement being represented. A single screen/frame placement can include all fields; a package/network placement should include fields only when they are uniform across the included inventory. For kind: publisher_ref, resolve the publisher placement first. Product-level slot_duration_seconds and loop_duration_seconds override publisher defaults for this offer; omitted values inherit. screen_resolution and motion are intrinsic publisher facts: when repeated at product level they MUST equal the publisher values. A mismatch is a conformance error rather than an override.", + "properties": { + "slot_duration_seconds": { + "type": "integer", + "description": "Scheduled duration of one ad slot in seconds (e.g., 10, 15, 30). This is an inventory fact used for loop and share calculations, not the creative-duration contract; format_options remains authoritative for accepted creative durations.", + "minimum": 1 + }, + "loop_duration_seconds": { + "type": "integer", + "description": "Duration of the full ad loop rotation in seconds. Buyers can derive nominal slot share as slot_duration_seconds / loop_duration_seconds. This is the canonical source for loop duration; the pricing-layer field in flat-rate-option.json is superseded by this placement-level declaration.", + "minimum": 1 + }, + "screen_resolution": { + "type": "object", + "description": "Physical screen resolution in pixels. Buyers can derive aspect ratio from width/height. This does not replace or broaden canonical format dimensions, which remain authoritative for creative acceptance and may describe a smaller content region within the physical screen.", + "properties": { + "width": { + "type": "integer", + "description": "Screen width in pixels.", + "minimum": 1 + }, + "height": { + "type": "integer", + "description": "Screen height in pixels.", + "minimum": 1 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "motion": { + "$ref": "/schemas/enums/dooh-motion-type.json", + "description": "Physical motion capability of the DOOH screen. This is discovery metadata, not an accepted-format declaration; effective format_options determines whether a particular full-motion, partial-motion, or static creative is accepted." + } + }, + "x-adcp-validation": { + "verifier_constraints": { + "slot_fits_loop": { + "left_path": "slot_duration_seconds", + "operator": "less_than_or_equal", + "right_path": "loop_duration_seconds", + "evaluate_after": "publisher_ref_resolution" + }, + "publisher_ref_resolution": { + "override_fields": ["slot_duration_seconds", "loop_duration_seconds"], + "inherit_when_omitted": true, + "must_equal_fields": ["screen_resolution", "motion"], + "identifier_merge": "union_by_type_and_value" + } + }, + "spec": "docs/media-buy/product-discovery/media-products.mdx#dooh-placement-attributes" + }, + "additionalProperties": true } }, "required": ["kind", "placement_id", "mode"], diff --git a/static/schemas/source/enums/dooh-motion-type.json b/static/schemas/source/enums/dooh-motion-type.json new file mode 100644 index 0000000000..403fb47175 --- /dev/null +++ b/static/schemas/source/enums/dooh-motion-type.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/dooh-motion-type.json", + "title": "DOOH Motion Type", + "description": "Physical motion capability of a digital out-of-home screen. This is inventory discovery metadata; canonical format_options remains the authoritative declaration of which creatives the placement accepts.", + "type": "string", + "enum": [ + "full_motion", + "partial_motion", + "static" + ], + "enumDescriptions": { + "full_motion": "Screen supports full-motion video playback (e.g., LED billboards, transit screens with video capability)", + "partial_motion": "Screen supports animated stills or limited motion (e.g., scrolling text, animated GIFs, HTML5 banners) but not full video", + "static": "Screen displays static images only (e.g., e-ink displays, printed poster replacements)" + }, + "examples": [ + "full_motion", + "static" + ] +} diff --git a/static/schemas/source/enums/identifier-types.json b/static/schemas/source/enums/identifier-types.json index 0b6bf40e4b..e7fd411441 100644 --- a/static/schemas/source/enums/identifier-types.json +++ b/static/schemas/source/enums/identifier-types.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/identifier-types.json", - "title": "Property Identifier Types", - "description": "Valid identifier types for property identification across different media types", + "title": "Identifier Types", + "description": "Valid identifier types for property and placement identification across different media types", "type": "string", "enum": [ "domain", @@ -42,7 +42,7 @@ "bundle_id": "Generic app bundle identifier", "venue_id": "DOOH venue identifier", "screen_id": "DOOH screen identifier within a venue", - "openooh_venue_type": "OpenOOH venue taxonomy classification", + "openooh_venue_type": "OpenOOH Venue Taxonomy enumeration ID, prefixed with the taxonomy authority and version for unambiguous interchange (for example openooh-1.1:20501 for Retail / Malls / Concourse). Bare or dotted hierarchy values are ambiguous and SHOULD NOT be emitted by new senders.", "rss_url": "RSS feed URL for podcast or content feed", "apple_podcast_id": "Apple Podcasts numeric ID", "spotify_collection_id": "Spotify show or collection URI", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 81c0c82755..bf17a16c14 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -1091,7 +1091,7 @@ }, "identifier-types": { "$ref": "/schemas/enums/identifier-types.json", - "description": "Valid identifier types for property identification across different media types" + "description": "Valid identifier types for property and placement identification across different media types" }, "publisher-identifier-types": { "$ref": "/schemas/enums/publisher-identifier-types.json", @@ -1117,6 +1117,10 @@ "$ref": "/schemas/enums/social-placement-surface.json", "description": "Declared social-placement surface classifications for social inventory" }, + "dooh-motion-type": { + "$ref": "/schemas/enums/dooh-motion-type.json", + "description": "Physical motion capabilities of digital out-of-home screens" + }, "task-status": { "$ref": "/schemas/enums/task-status.json", "description": "Standardized task status values based on A2A TaskState enum" diff --git a/static/schemas/source/pricing-options/flat-rate-option.json b/static/schemas/source/pricing-options/flat-rate-option.json index 413348a486..a11e663d95 100644 --- a/static/schemas/source/pricing-options/flat-rate-option.json +++ b/static/schemas/source/pricing-options/flat-rate-option.json @@ -52,8 +52,14 @@ }, "loop_duration_seconds": { "type": "integer", - "description": "Duration of the ad loop rotation in seconds", - "minimum": 1 + "description": "Deprecated compatibility copy of the placement-level dooh_placement_attributes.loop_duration_seconds, which is the canonical source. Retained for backward compatibility; new integrations read the placement-level field. When both are present they MUST agree. A product that offers different loop durations under different prices MUST expose distinct placements or products rather than vary this compatibility copy by pricing option.", + "minimum": 1, + "x-adcp-validation": { + "verifier_constraints": { + "must_equal_effective_placement_field": "dooh_placement_attributes.loop_duration_seconds" + }, + "spec": "docs/media-buy/advanced-topics/pricing-models.mdx#flat-rate" + } }, "min_plays_per_hour": { "type": "integer", diff --git a/tests/placement-catalog-schema.test.cjs b/tests/placement-catalog-schema.test.cjs index a5118821c8..818c5b2c22 100644 --- a/tests/placement-catalog-schema.test.cjs +++ b/tests/placement-catalog-schema.test.cjs @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); +const { isDeepStrictEqual } = require('node:util'); const Ajv = require('ajv'); const addFormats = require('ajv-formats'); @@ -30,6 +31,42 @@ async function compile(schemaId) { return ajv.compileAsync(JSON.parse(fs.readFileSync(schemaPathFromId(schemaId), 'utf8'))); } +function resolveDoohPlacement(publisherPlacement = {}, productPlacement = {}) { + const publisherAttributes = publisherPlacement.dooh_placement_attributes || {}; + const productAttributes = productPlacement.dooh_placement_attributes || {}; + for (const field of ['screen_resolution', 'motion']) { + if ( + publisherAttributes[field] !== undefined && + productAttributes[field] !== undefined && + !isDeepStrictEqual(publisherAttributes[field], productAttributes[field]) + ) { + throw new Error(`${field} conflicts with the publisher placement`); + } + } + + const effective = { ...publisherAttributes, ...productAttributes }; + if ( + effective.slot_duration_seconds !== undefined && + effective.loop_duration_seconds !== undefined && + effective.slot_duration_seconds > effective.loop_duration_seconds + ) { + throw new Error('slot_duration_seconds exceeds loop_duration_seconds'); + } + const identifiers = [...(publisherPlacement.identifiers || []), ...(productPlacement.identifiers || [])].filter( + (identifier, index, all) => + all.findIndex((candidate) => candidate.type === identifier.type && candidate.value === identifier.value) === index + ); + return { dooh_placement_attributes: effective, identifiers }; +} + +function validateDoohPricingLoop(effectivePlacement, pricingOption) { + const placementLoop = effectivePlacement.dooh_placement_attributes?.loop_duration_seconds; + const pricingLoop = pricingOption.parameters?.loop_duration_seconds; + if (placementLoop !== undefined && pricingLoop !== undefined && placementLoop !== pricingLoop) { + throw new Error('pricing loop_duration_seconds conflicts with the effective placement'); + } +} + function validProduct(overrides = {}) { return { product_id: 'homepage_sponsorship', @@ -600,3 +637,148 @@ test('format options can be referenced by publisher domain or product-local ID', false ); }); + +test('placement-definition accepts dooh_placement_attributes and identifiers', async () => { + const validate = await compile('/schemas/core/placement-definition.json'); + const placement = { + placement_id: 'mall_concourse_north', + name: 'Mall concourse north LED', + property_ids: ['mall_concourse_network'], + channels: ['dooh'], + identifiers: [ + { type: 'screen_id', value: 'mall-north-001' }, + { type: 'openooh_venue_type', value: 'openooh-1.1:20501' } + ], + dooh_placement_attributes: { + slot_duration_seconds: 15, + loop_duration_seconds: 120, + screen_resolution: { width: 1920, height: 1080 }, + motion: 'full_motion' + } + }; + + assert.equal(validate(placement), true, JSON.stringify(validate.errors, null, 2)); +}); + +test('product placement accepts dooh_placement_attributes and identifiers', async () => { + const validate = await compile('/schemas/core/placement.json'); + const placement = { + kind: 'seller_inline', + placement_id: 'mall_entrance_screen', + name: 'Mall Entrance Digital Screen', + mode: 'targetable', + identifiers: [ + { type: 'venue_id', value: 'geopath:30961' } + ], + dooh_placement_attributes: { + slot_duration_seconds: 10, + loop_duration_seconds: 60, + screen_resolution: { width: 3840, height: 2160 }, + motion: 'full_motion' + } + }; + + assert.equal(validate(placement), true, JSON.stringify(validate.errors, null, 2)); +}); + +test('canonical list_products placements preserve DOOH inventory facts', async () => { + const validate = await compile('/schemas/core/canonical-placement.json'); + const placement = { + kind: 'seller_inline', + placement_id: 'central_concourse_portrait_screens', + publisher_domain: 'metro-media.example', + name: 'Central concourse portrait screens', + mode: 'included', + identifiers: [ + { type: 'venue_id', value: 'metro:central-concourse' } + ], + dooh_placement_attributes: { + slot_duration_seconds: 10, + loop_duration_seconds: 80, + screen_resolution: { width: 1080, height: 1920 }, + motion: 'full_motion' + } + }; + + assert.equal(validate(placement), true, JSON.stringify(validate.errors, null, 2)); +}); + +test('dooh_placement_attributes rejects invalid motion type', async () => { + const validate = await compile('/schemas/core/placement.json'); + const placement = { + kind: 'seller_inline', + placement_id: 'bus_shelter_01', + name: 'Bus Shelter Panel', + mode: 'targetable', + dooh_placement_attributes: { + motion: 'invalid_motion_type' + } + }; + + assert.equal(validate(placement), false); +}); + +test('DOOH placement schemas declare cross-field and publisher-resolution rules', () => { + const productPlacement = require('../static/schemas/source/core/placement.json'); + const canonicalPlacement = require('../static/schemas/source/core/canonical-placement.json'); + const publisherPlacement = require('../static/schemas/source/core/placement-definition.json'); + const productRules = productPlacement.properties.dooh_placement_attributes['x-adcp-validation'].verifier_constraints; + const canonicalRules = canonicalPlacement.properties.dooh_placement_attributes['x-adcp-validation'].verifier_constraints; + const publisherRules = publisherPlacement.properties.dooh_placement_attributes['x-adcp-validation'].verifier_constraints; + + assert.equal(productRules.slot_fits_loop.operator, 'less_than_or_equal'); + assert.equal(productRules.slot_fits_loop.evaluate_after, 'publisher_ref_resolution'); + assert.equal(publisherRules.slot_fits_loop.operator, 'less_than_or_equal'); + assert.deepEqual(productRules.publisher_ref_resolution.must_equal_fields, ['screen_resolution', 'motion']); + assert.deepEqual(canonicalRules, productRules); +}); + +test('DOOH publisher and product attributes resolve before slot-to-loop validation', () => { + const publisher = { + identifiers: [ + { type: 'screen_id', value: 'space:screen-1' }, + { type: 'openooh_venue_type', value: 'openooh-1.1:20501' } + ], + dooh_placement_attributes: { + slot_duration_seconds: 15, + loop_duration_seconds: 60, + screen_resolution: { width: 1920, height: 1080 }, + motion: 'full_motion' + } + }; + + const effective = resolveDoohPlacement(publisher, { + identifiers: [ + { type: 'screen_id', value: 'space:screen-1' }, + { type: 'venue_id', value: 'geopath:30961' } + ], + dooh_placement_attributes: { slot_duration_seconds: 10 } + }); + assert.deepEqual(effective.dooh_placement_attributes, { + slot_duration_seconds: 10, + loop_duration_seconds: 60, + screen_resolution: { width: 1920, height: 1080 }, + motion: 'full_motion' + }); + assert.deepEqual(effective.identifiers, [ + { type: 'screen_id', value: 'space:screen-1' }, + { type: 'openooh_venue_type', value: 'openooh-1.1:20501' }, + { type: 'venue_id', value: 'geopath:30961' } + ]); + assert.throws( + () => resolveDoohPlacement(publisher, { dooh_placement_attributes: { slot_duration_seconds: 90 } }), + /exceeds loop_duration_seconds/ + ); + assert.throws( + () => resolveDoohPlacement(publisher, { dooh_placement_attributes: { motion: 'static' } }), + /conflicts with the publisher placement/ + ); + + assert.doesNotThrow(() => + validateDoohPricingLoop(effective, { parameters: { type: 'dooh', loop_duration_seconds: 60 } }) + ); + assert.throws( + () => validateDoohPricingLoop(effective, { parameters: { type: 'dooh', loop_duration_seconds: 90 } }), + /conflicts with the effective placement/ + ); +});