diff --git a/packages/data/src/data-manager.ts b/packages/data/src/data-manager.ts index 1aea12f0..1d250144 100644 --- a/packages/data/src/data-manager.ts +++ b/packages/data/src/data-manager.ts @@ -45,7 +45,8 @@ import { VariationStatuses, VariationAllocation, eventType, - GenericListMatchingOptions + GenericListMatchingOptions, + RuleObjectAudience } from '@convertcom/js-sdk-types'; import { @@ -54,6 +55,7 @@ import { DATA_ENTITIES_MAP, ERROR_MESSAGES, MESSAGES, + MutualExclusionRuleType, RuleError, SegmentsKeys, SystemEvents, @@ -62,6 +64,23 @@ import { import {DataStoreManager} from './data-store-manager'; const LOCAL_STORE_LIMIT = 10000; + +/** + * qs-03 / SDK-3: minimal shape of a single `bucketed_into_experience_key` + * mutual-exclusion rule element, covering only the fields `_resolveBucketingExclusion` + * reads. NOT part of the generated `RuleElement` union (`packages/types/src/config` is + * auto-generated from the OpenAPI spec and must never be hand-edited to add this rule + * type) -- rule items are matched against this local interface instead. + */ +interface BucketingExclusionRuleItem { + rule_type: string; + value: string; + matching?: { + match_type?: string; + negated?: boolean; + }; +} + /** * Provides logic for data. Stores bucket with help of dataStore if it's provided * @category Modules @@ -381,7 +400,8 @@ export class DataManager implements DataManagerInterface { audiencesToCheck, visitorProperties, 'audience', - identityField + identityField, + visitorId ); // Return rule errors if present matchedErrors = matchedAudiences.filter((match) => @@ -1213,17 +1233,93 @@ export class DataManager implements DataManagerInterface { return true; } + /** + * qs-03 / SDK-3: walk an audience's rule tree (same OR -> AND -> OR_WHEN traversal + * shape as `RuleManager.isRuleMatched`) looking for a `bucketed_into_experience_key` + * mutual-exclusion rule element. Documented assumption: at most one such rule per + * exclusion audience -- returns the first one found, or `null` if the audience + * carries no mutual-exclusion rule (the generic rule-matching path applies instead). + * @param {RuleObjectAudience} rules + * @return {BucketingExclusionRuleItem | null} + * @private + */ + private _isBucketingExclusionRule( + rules: RuleObjectAudience + ): BucketingExclusionRuleItem | null { + if (!arrayNotEmpty(rules?.OR)) return null; + for (const andBlock of rules.OR) { + if (!arrayNotEmpty(andBlock?.AND)) continue; + for (const orWhenBlock of andBlock.AND) { + if (!arrayNotEmpty(orWhenBlock?.OR_WHEN)) continue; + for (const ruleItem of orWhenBlock.OR_WHEN) { + if ( + (ruleItem as unknown as BucketingExclusionRuleItem)?.rule_type === + MutualExclusionRuleType.BUCKETED_INTO_EXPERIENCE_KEY + ) { + return ruleItem as unknown as BucketingExclusionRuleItem; + } + } + } + } + return null; + } + + /** + * qs-03 / SDK-3: presence-only resolution of a `bucketed_into_experience_key` + * mutual-exclusion rule. Read-only -- resolves the rule's target experience by KEY + * via `getEntity` and checks ONLY whether the visitor's stored bucketing map + * (`getData().bucketing`) already carries an entry keyed by the target's id; it + * NEVER calls `retrieveVariation`/the BucketingManager, so evaluating this rule can + * never bucket the target experience as a side effect (AC5). If the target key does + * not resolve in the served config, warns naming the unresolved key and treats the + * visitor as not bucketed into it (`bucketedRaw = false`) rather than failing the + * whole audience evaluation. Negation is applied LAST, after the raw presence check. + * @param {BucketingExclusionRuleItem} rule + * @param {string} visitorId + * @return {boolean} + * @private + */ + private _resolveBucketingExclusion( + rule: BucketingExclusionRuleItem, + visitorId: string + ): boolean { + const target = this.getEntity( + rule.value, + 'experiences' + ) as ConfigExperience; + let bucketedRaw = false; + if (!target) { + this._loggerManager?.warn?.( + 'DataManager._resolveBucketingExclusion()', + ERROR_MESSAGES.BUCKETING_EXCLUSION_TARGET_NOT_FOUND.replace( + '#', + rule.value + ) + ); + } else { + const {bucketing} = this.getData(visitorId) || {}; + bucketedRaw = Boolean((bucketing || {})[String(target.id)]); + } + return rule.matching?.negated ? !bucketedRaw : bucketedRaw; + } + /** * Get audiences that meet the visitorProperties * @param {Array>} items * @param {Record} visitorProperties + * @param {string} entityType + * @param {IdentityField=} field Defaults to 'id' + * @param {string=} visitorId Required only when an item's rules resolve to a + * `bucketed_into_experience_key` mutual-exclusion rule (qs-03 / SDK-3); unused by + * the generic rule-matching path. * @return {Array | RuleError>} */ filterMatchedRecordsWithRule( items: Array>, visitorProperties: Record, entityType: string, - field: IdentityField = 'id' + field: IdentityField = 'id', + visitorId?: string ): Array | RuleError> { this._loggerManager?.trace?.( 'DataManager.filterMatchedRecordsWithRule()', @@ -1237,11 +1333,16 @@ export class DataManager implements DataManagerInterface { if (arrayNotEmpty(items)) { for (let i = 0, length = items.length; i < length; i++) { if (!items?.[i]?.rules) continue; - match = this._ruleManager.isRuleMatched( - visitorProperties, - items[i].rules, - `${camelCase(entityType)} #${items[i][field]}` - ); + const exclusionRule = this._isBucketingExclusionRule(items[i].rules); + if (exclusionRule) { + match = this._resolveBucketingExclusion(exclusionRule, visitorId); + } else { + match = this._ruleManager.isRuleMatched( + visitorProperties, + items[i].rules, + `${camelCase(entityType)} #${items[i][field]}` + ); + } if (match === true) { matchedRecords.push(items[i]); } else if (match !== false) { diff --git a/packages/data/tests/mutual-exclusion-rule-fixture.json b/packages/data/tests/mutual-exclusion-rule-fixture.json new file mode 100644 index 00000000..c4f86f64 --- /dev/null +++ b/packages/data/tests/mutual-exclusion-rule-fixture.json @@ -0,0 +1,138 @@ +{ + "specRef": "_bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md#inline-cross-sdk-fixture", + "description": "qs-03 mutual-exclusion audience rule (bucketed_into_experience_key) cross-SDK fixture. The 8 rows and their expected values are the normative contract, identical across every sibling SDK spec (JS/PHP/Python/Ruby/Android/iOS). Consumed verbatim by packages/rules/tests, packages/data/tests, and packages/js-sdk/tests/browser via a relative filesystem read -- never duplicated. Changing an expected value here is a cross-SDK contract change, not a local test tweak.", + "config": { + "experiences": [ + { + "id": "100111", + "name": "exp-a", + "key": "exp-a", + "type": "a/b_fullstack", + "status": "active", + "audiences": [], + "goals": [], + "variations": [ + { + "id": "100901", + "name": "exp-a variation", + "status": "running", + "is_baseline": true + } + ] + }, + { + "id": "100222", + "name": "exp-b", + "key": "exp-b", + "type": "a/b_fullstack", + "status": "active", + "audiences": [], + "goals": [], + "variations": [ + { + "id": "100902", + "name": "exp-b variation", + "status": "running", + "is_baseline": true + } + ] + } + ] + }, + "ruleDefaults": { + "rule_type": "bucketed_into_experience_key", + "matching": { + "match_type": "equals" + } + }, + "rows": [ + { + "row": 1, + "description": "empty stored bucketing, non-negated, target exp-a -> visitor not in exp-a -> not matched", + "storedBucketing": {}, + "dataStoreOnly": false, + "ruleValue": "exp-a", + "negated": false, + "expectedMatched": false, + "expectWarn": false + }, + { + "row": 2, + "description": "empty stored bucketing, negated, target exp-a -> visitor not in exp-a -> NOT-in-exp-a matches", + "storedBucketing": {}, + "dataStoreOnly": false, + "ruleValue": "exp-a", + "negated": true, + "expectedMatched": true, + "expectWarn": false + }, + { + "row": 3, + "description": "stored bucketing has exp-a's variation (in memory), non-negated, target exp-a -> matched", + "storedBucketing": { + "100111": "100901" + }, + "dataStoreOnly": false, + "ruleValue": "exp-a", + "negated": false, + "expectedMatched": true, + "expectWarn": false + }, + { + "row": 4, + "description": "stored bucketing has exp-a's variation (in memory), negated, target exp-a -> visitor IS in exp-a -> NOT-in-exp-a does not match", + "storedBucketing": { + "100111": "100901" + }, + "dataStoreOnly": false, + "ruleValue": "exp-a", + "negated": true, + "expectedMatched": false, + "expectWarn": false + }, + { + "row": 5, + "description": "stored bucketing has exp-b's variation only (in memory), negated, target exp-a -> visitor not in exp-a -> NOT-in-exp-a matches", + "storedBucketing": { + "100222": "100902" + }, + "dataStoreOnly": false, + "ruleValue": "exp-a", + "negated": true, + "expectedMatched": true, + "expectWarn": false + }, + { + "row": 6, + "description": "empty stored bucketing, non-negated, target exp-zz absent from config -> bucketedRaw resolves false -> not matched, warns naming the unresolved key", + "storedBucketing": {}, + "dataStoreOnly": false, + "ruleValue": "exp-zz", + "negated": false, + "expectedMatched": false, + "expectWarn": true + }, + { + "row": 7, + "description": "empty stored bucketing, negated, target exp-zz absent from config -> bucketedRaw resolves false -> NOT-in-exp-zz matches, warns naming the unresolved key", + "storedBucketing": {}, + "dataStoreOnly": false, + "ruleValue": "exp-zz", + "negated": true, + "expectedMatched": true, + "expectWarn": true + }, + { + "row": 8, + "description": "exp-a's variation stored ONLY in the configured DataStore (never written to the in-memory visitor store), negated, target exp-a -> getData() merges the DataStore in -> visitor IS in exp-a -> NOT-in-exp-a does not match", + "storedBucketing": { + "100111": "100901" + }, + "dataStoreOnly": true, + "ruleValue": "exp-a", + "negated": true, + "expectedMatched": false, + "expectWarn": false + } + ] +} diff --git a/packages/data/tests/mutual-exclusion-rule.tests.ts b/packages/data/tests/mutual-exclusion-rule.tests.ts new file mode 100644 index 00000000..30a9be7a --- /dev/null +++ b/packages/data/tests/mutual-exclusion-rule.tests.ts @@ -0,0 +1,297 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * SDK-3 (RED) -- qs-03 mutual-exclusion audience rule (`bucketed_into_experience_key`). + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md + * "The contract (normative)" + "Inline cross-SDK fixture" + AC1/AC4/AC5/AC8. + * + * This is the JS SDK's own copy of the identical cross-SDK 8-row fixture + * (packages/data/tests/mutual-exclusion-rule-fixture.json) -- every sibling SDK + * (PHP/Python/Ruby/Android/iOS) consumes the same 8 rows verbatim; the expected + * `matched` values are the normative contract, not a local test tweak. + * + * Driven through the REAL audience-evaluation path (`DataManager.matchRulesByField`, + * which internally calls `filterMatchedRecordsWithRule` -> `RuleManager.isRuleMatched`) + * -- never a private helper called directly -- so this test survives whichever exact + * seam shape the GREEN phase lands (`filterMatchedRecordsWithRule` resolution per the + * story's seam contract). `matchRulesByField`'s public signature (visitorId, identity, + * identityField, attributes) is stable regardless of that internal choice. + * + * RED-phase note: the resolution seam does not exist yet in `data-manager.ts`. Today, + * `RuleManager._processRuleItem` fail-closes on this unknown `rule_type` (no `key` + * field, empty `visitorProperties` fails `objectNotEmpty`) and always returns `false` + * with negation NOT applied on the fall-through (verified fact in the spec's "Verified + * facts" section). That means rows expecting `matched: false` (1, 4, 6, 8) trivially + * pass pre-seam -- this is expected, standard contract-table behavior: the fixture's + * paired true/false rows (1 vs 2, 3 vs 4, negated toggles, stored-state toggles) jointly + * constrain the implementation so that no degenerate function (always-true, + * always-false) can satisfy the whole table. Rows 2, 3, 5, 7 (expecting `true`) and the + * warn assertions on rows 6/7 are the rows that actually fail red today; see this + * feature's decision log for the full reasoning. + */ +import 'mocha'; +import {expect} from 'chai'; +import {BucketingManager as bm} from '@convertcom/js-sdk-bucketing'; +import {RuleManager as rm} from '@convertcom/js-sdk-rules'; +import {EventManager as em} from '@convertcom/js-sdk-event'; +import {ApiManager as am} from '@convertcom/js-sdk-api'; +import {DataManager as dm} from '../src/data-manager'; +import testConfig from './test-config.json'; +import fixture from './mutual-exclusion-rule-fixture.json'; +import { + Config as ConfigType, + ConfigExperience, + ConfigAudienceTypes, + GenericListMatchingOptions +} from '@convertcom/js-sdk-types'; +import {objectDeepMerge} from '@convertcom/js-sdk-utils'; +import {defaultConfig} from '../../js-sdk/src/config/default'; + +interface FixtureRow { + row: number; + description: string; + storedBucketing: Record; + dataStoreOnly: boolean; + ruleValue: string; + negated: boolean; + expectedMatched: boolean; + expectWarn: boolean; +} + +// Minimal DataStore double -- mirrors the `DataStore` class in data-manager.tests.ts, +// used only for row 8 (bucketing present ONLY in the DataStore, never in-memory). +class FixtureDataStore { + data: Record = {}; + get(key: string): any { + if (!key) return this.data; + return this.data[key.toString()]; + } + set(key: string, value: any): void { + if (!key) throw new Error('Invalid DataStore key!'); + this.data[key.toString()] = value; + } +} + +// Fake LogManager -- collects warn() calls for the AC8 assertion without pulling in a +// mocking library (mirrors data-manager-preview-decision.tests.ts's no-sinon convention: +// neither this package nor js-sdk-data declares sinon as a dependency). +class SpyLogger { + warnCalls: any[][] = []; + trace(): void {} + debug(): void {} + info(): void {} + log(): void {} + error(): void {} + warn(...args: any[]): void { + this.warnCalls.push(args); + } +} + +const ROWS = fixture.rows as FixtureRow[]; +const ACCOUNT_ID = 'qs-03-account'; +const PROJECT_ID = 'qs-03-project'; +// The experience under test: it carries the exclusion audience naming the row's +// target (mirrors AC2's "experience B carries the exclusion audience"). +const EXPERIENCE_UNDER_TEST_KEY = 'exp-b'; +const AUDIENCE_ID = 'qs-03-exclusion-audience'; + +// Shared dependency managers (mirrors data-manager.tests.ts / cross-sdk-vectors.tests.ts +// pattern) -- stateless w.r.t. rule evaluation, no per-row setup/teardown needed for these. +const sharedConfiguration = objectDeepMerge( + testConfig, + defaultConfig, + {} +) as unknown as ConfigType; +const bucketingManager = new bm(sharedConfiguration); +const ruleManager = new rm(sharedConfiguration); +const eventManager = new em(sharedConfiguration); +const apiManager = new am(sharedConfiguration, {eventManager}); + +/** + * Builds the audience carrying the row's `bucketed_into_experience_key` rule, using the + * fixture's own `ruleDefaults` (rule_type + match_type) merged with the row's + * value/negated -- so no rule shape is duplicated by hand per row. + */ +function buildExclusionAudience(row: FixtureRow) { + return { + id: AUDIENCE_ID, + key: AUDIENCE_ID, + type: ConfigAudienceTypes.TRANSIENT, + status: 'active', + rules: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + ...fixture.ruleDefaults, + matching: { + ...fixture.ruleDefaults.matching, + negated: row.negated + }, + value: row.ruleValue + } + ] + } + ] + } + ] + } + }; +} + +/** + * Builds a fresh DataManager for one fixture row: clones the fixture's exp-a/exp-b + * config, attaches the row's exclusion audience to `exp-b` under `matching_options: + * ALL` (single audience, so ALL/ANY are equivalent here -- AC6's combination semantics + * are covered at the js-sdk integration layer instead), and seeds the row's stored + * bucketing either in-memory (`putData`) or, for row 8, ONLY into a DataStore double + * (bypassing in-memory entirely, per the fixture's `dataStoreOnly` flag). + */ +function buildRowDataManager(row: FixtureRow, visitorId: string) { + const logger = new SpyLogger(); + const audience = buildExclusionAudience(row); + const experiences = ( + fixture.config.experiences as unknown as ConfigExperience[] + ).map((experience) => + experience.key === EXPERIENCE_UNDER_TEST_KEY + ? { + ...experience, + audiences: [AUDIENCE_ID], + settings: { + matching_options: {audiences: GenericListMatchingOptions.ALL} + } + } + : experience + ); + const config = { + data: { + account_id: ACCOUNT_ID, + project: {id: PROJECT_ID}, + experiences, + audiences: [audience] + } + } as unknown as ConfigType; + + const dataManager = new dm(config, { + bucketingManager, + ruleManager, + eventManager, + apiManager, + loggerManager: logger as any + }); + + let dataStore: FixtureDataStore | null = null; + if (Object.keys(row.storedBucketing).length) { + if (row.dataStoreOnly) { + dataStore = new FixtureDataStore(); + dataManager.setDataStore(dataStore); + const storeKey = dataManager.getStoreKey(visitorId); + // Write directly through the DataStoreManager, bypassing DataManager.putData + // entirely -- proves the bucketing state is visible ONLY via the DataStore, + // never via the in-memory `_bucketedVisitors` map (row 8's contract). + dataManager.dataStoreManager.set(storeKey, {bucketing: row.storedBucketing}); + } else { + dataManager.putData(visitorId, {bucketing: row.storedBucketing}); + } + } + + return {dataManager, logger}; +} + +describe('Mutual-exclusion audience rule (bucketed_into_experience_key) -- qs-03 fixture (AC1, AC4, AC5, AC8)', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + ROWS.forEach((row) => { + it(`row ${row.row}: ${row.description}`, function () { + const visitorId = `visitor-row-${row.row}`; + const {dataManager, logger} = buildRowDataManager(row, visitorId); + + // AC5 read-only spies -- armed AFTER seeding, so this row's setup writes/reads + // are excluded and only the evaluation itself is measured. + let putDataCallCount = 0; + const originalPutData = dataManager.putData.bind(dataManager); + dataManager.putData = ((...args: Parameters) => { + putDataCallCount++; + return originalPutData(...args); + }) as typeof dataManager.putData; + + const targetExperience = ( + fixture.config.experiences as Array<{key: string; id: string}> + ).find((experience) => experience.key === row.ruleValue); + let targetRetrieveVariationCallCount = 0; + if (targetExperience) { + // `retrieveVariation` is a private method -- accessed via `any` the same way + // data-manager-preview-decision.tests.ts reaches into `_bucketedVisitors` + // directly; TS `private` is compile-time only. + const originalRetrieveVariation = ( + dataManager as any + ).retrieveVariation.bind(dataManager); + (dataManager as any).retrieveVariation = ( + experienceId: string, + variationId: string + ) => { + if (String(experienceId) === String(targetExperience.id)) { + targetRetrieveVariationCallCount++; + } + return originalRetrieveVariation(experienceId, variationId); + }; + } + + // AC4: driven with an EMPTY visitorProperties object throughout. + const result = dataManager.matchRulesByField( + visitorId, + EXPERIENCE_UNDER_TEST_KEY, + 'key', + {visitorProperties: {}, ignoreLocationProperties: true} + ); + + expect(Boolean(result), `row ${row.row} matched outcome`).to.equal( + row.expectedMatched + ); + + // AC5 -- read-only: no bucketing of the target, no store write triggered by the + // exclusion evaluation itself. + expect( + putDataCallCount, + `row ${row.row} putData call count (AC5 read-only)` + ).to.equal(0); + if (targetExperience) { + expect( + targetRetrieveVariationCallCount, + `row ${row.row} retrieveVariation(target) call count (AC5 read-only, no bucketing of target)` + ).to.equal(0); + } + + // AC8 -- rows 6/7 (unknown target "exp-zz") must warn, naming the unresolved key. + if (row.expectWarn) { + const warnedForTarget = logger.warnCalls.some((args) => + args.some( + (arg) => typeof arg === 'string' && arg.includes(row.ruleValue) + ) + ); + expect( + warnedForTarget, + `row ${row.row} expected a warning naming "${row.ruleValue}"` + ).to.equal(true); + } else { + expect( + logger.warnCalls.length, + `row ${row.row} expected no warning` + ).to.equal(0); + } + }); + }); + + it('loaded the full 8-row cross-SDK fixture (AC1 completeness guard)', function () { + expect(ROWS).to.have.lengthOf(8); + expect(ROWS.filter((row) => row.expectedMatched)).to.have.lengthOf(4); + expect(ROWS.filter((row) => row.expectWarn)).to.have.lengthOf(2); + }); +}); diff --git a/packages/enums/index.ts b/packages/enums/index.ts index 04a18afd..f562adcc 100644 --- a/packages/enums/index.ts +++ b/packages/enums/index.ts @@ -15,6 +15,7 @@ export * from './src/feature-status'; export * from './src/goal-data-key'; export * from './src/log-level'; export * from './src/log-method'; +export * from './src/mutual-exclusion-rule-type'; export * from './src/project-type'; export * from './src/rule-error'; export * from './src/system-events'; diff --git a/packages/enums/src/dictionary.ts b/packages/enums/src/dictionary.ts index dbf76e28..38448e53 100644 --- a/packages/enums/src/dictionary.ts +++ b/packages/enums/src/dictionary.ts @@ -26,7 +26,9 @@ export const ERROR_MESSAGES = { PREVIEW_EXPERIENCE_NOT_FOUND: 'Context.setPreview() could not resolve the requested experience', PREVIEW_VARIATION_NOT_FOUND: - 'Context.setPreview() could not resolve the requested variation on the experience' + 'Context.setPreview() could not resolve the requested variation on the experience', + BUCKETING_EXCLUSION_TARGET_NOT_FOUND: + 'Mutual-exclusion rule target experience key "#" not found in served config; treating visitor as not bucketed into it.' }; export const MESSAGES = { CONFIG_DATA_UPDATED: 'Config Data updated', diff --git a/packages/enums/src/mutual-exclusion-rule-type.ts b/packages/enums/src/mutual-exclusion-rule-type.ts new file mode 100644 index 00000000..1b1f7c8e --- /dev/null +++ b/packages/enums/src/mutual-exclusion-rule-type.ts @@ -0,0 +1,9 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ +export enum MutualExclusionRuleType { + BUCKETED_INTO_EXPERIENCE_KEY = 'bucketed_into_experience_key' +} diff --git a/packages/js-sdk/tests/browser/mutual-exclusion-rule.spec.ts b/packages/js-sdk/tests/browser/mutual-exclusion-rule.spec.ts new file mode 100644 index 00000000..b0d86cca --- /dev/null +++ b/packages/js-sdk/tests/browser/mutual-exclusion-rule.spec.ts @@ -0,0 +1,207 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * SDK-3 (RED) -- qs-03 mutual-exclusion audience rule (`bucketed_into_experience_key`), + * verified inside a real headless Chromium browser against the actual built UMD bundle + * (not Node/Mocha) -- the browser gate mandated by qs-03's "Mandated tests" section + * ("the tracking script vendors these rule packages, so browser-engine equivalence + * must be proven even though the TS does not serve this rule type by default"). + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md + * "Inline cross-SDK fixture" (AC1) + "Mandated tests" (browser gate, MUST). + * + * Reads the SAME 8-row fixture the Node/Mocha unit suite reads + * (packages/data/tests/mutual-exclusion-rule-fixture.json) directly off disk -- never + * duplicated -- and drives every row through the real public SDK surface exposed on + * the UMD bundle's `window.ConvertSDK` global, mirroring golden-vectors.spec.ts's + * pattern (fresh `ConvertSDK.default({data})` per case, real `Context.runExperience()`). + * + * Each row attaches its `bucketed_into_experience_key` rule to a TRANSIENT audience on + * `exp-b` (the same "experience under test" convention used by the Node unit suite and + * the js-sdk integration suite), so "served" (non-null) iff the negated-exclusion + * audience matches. Stored bucketing is seeded either by reaching into the Context's + * `_dataManager` (a runtime-accessible instance property -- TypeScript `private` is + * compile-time only, and the built UMD bundle carries no privacy at all) to call the + * exact same `putData`/`dataStoreManager.set` seams the Node unit suite uses directly, + * NOT by re-running experiences to bucket -- avoids a circular seeding dependency for + * row 5 (which seeds exp-b's OWN bucketing entry, the very experience hosting the rule + * under test). See this feature's decision log for the full reasoning. + * + * RED-phase note: this spec is authored only in the RED phase -- it is NOT run here (it + * requires the built UMD bundle AND a non-sandboxed browser launch). The conductor runs + * it in the GREEN/full-suite phase, once the resolution seam lands in + * `packages/data/src/data-manager.ts` and the UMD bundle is rebuilt. + */ +import {test, expect, Page} from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface FixtureRow { + row: number; + description: string; + storedBucketing: Record; + dataStoreOnly: boolean; + ruleValue: string; + negated: boolean; + expectedMatched: boolean; + expectWarn: boolean; +} + +interface Fixture { + config: {experiences: Array>}; + ruleDefaults: {rule_type: string; matching: {match_type: string}}; + rows: FixtureRow[]; +} + +// Same fixture the Node/Mocha unit runner reads +// (packages/data/tests/mutual-exclusion-rule.tests.ts) -- resolved via a relative +// filesystem path so no new package dependency edge is introduced. +const FIXTURE_PATH = path.resolve( + __dirname, + '../../../data/tests/mutual-exclusion-rule-fixture.json' +); +const FIXTURE: Fixture = JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')); + +const EXPERIENCE_UNDER_TEST_KEY = 'exp-b'; +const AUDIENCE_ID = 'qs-03-exclusion-audience'; + +interface RowRunResult { + row: number; + description: string; + expectedMatched: boolean; + matched: boolean; +} + +// Drives every fixture row through the real UMD-bundled SDK in a single page context: +// one navigation, one page.evaluate() round-trip building a fresh ConvertSDK instance +// per row (direct `data` config, no network) and calling the real public +// `Context.runExperience()` seam -- no rule-matching logic is re-implemented here, only +// the fixture's per-row config/seeding is assembled in-page and handed to the real +// bundled SDK. +async function runRowsInBrowser( + page: Page, + fixture: Fixture +): Promise { + await page.goto('/umd.html'); + await page.waitForFunction( + () => typeof (window as any).ConvertSDK?.default === 'function' + ); + return page.evaluate( + ({rows, experiencesTemplate, ruleDefaults, audienceId, experienceUnderTestKey}) => { + const w = window as any; + return rows.map((row: FixtureRow) => { + const visitorId = `browser-visitor-row-${row.row}`; + const audience = { + id: audienceId, + key: audienceId, + type: 'transient', + status: 'active', + rules: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + ...ruleDefaults, + matching: {...ruleDefaults.matching, negated: row.negated}, + value: row.ruleValue + } + ] + } + ] + } + ] + } + }; + const experiences = experiencesTemplate.map((experience: Record) => + experience.key === experienceUnderTestKey + ? { + ...experience, + audiences: [audienceId], + settings: {matching_options: {audiences: 'all'}} + } + : experience + ); + const config = { + data: { + account_id: 'browser-qs-03-account', + project: {id: 'browser-qs-03-project'}, + experiences, + audiences: [audience] + } + }; + const sdk = new w.ConvertSDK.default(config); + const context = sdk.createContext(visitorId, {}); + + if (Object.keys(row.storedBucketing).length) { + if (row.dataStoreOnly) { + // Seed ONLY the DataStore, bypassing the in-memory visitor store entirely + // -- proves `getData()` merges the DataStore in (row 8's contract). + const dataStore = { + data: {} as Record, + get(key: string) { + return this.data[key]; + }, + set(key: string, value: any) { + this.data[key] = value; + } + }; + context._dataManager.setDataStore(dataStore); + const storeKey = context._dataManager.getStoreKey(visitorId); + context._dataManager.dataStoreManager.set(storeKey, { + bucketing: row.storedBucketing + }); + } else { + // In-memory seeding -- same seam the Node unit suite calls directly. + context._dataManager.putData(visitorId, {bucketing: row.storedBucketing}); + } + } + + const result = context.runExperience(experienceUnderTestKey, { + visitorProperties: {}, // AC4 + ignoreLocationProperties: true + }); + + return { + row: row.row, + description: row.description, + expectedMatched: row.expectedMatched, + matched: Boolean(result) + }; + }); + }, + { + rows: fixture.rows, + experiencesTemplate: fixture.config.experiences, + ruleDefaults: fixture.ruleDefaults, + audienceId: AUDIENCE_ID, + experienceUnderTestKey: EXPERIENCE_UNDER_TEST_KEY + } + ); +} + +test.describe('Mutual-exclusion audience rule (qs-03 / SDK-3) -- real UMD bundle in headless Chromium', () => { + test('all 8 fixture rows resolve identically through the real bundled DataManager/RuleManager (browser-engine equivalence, AC1)', async ({ + page + }) => { + const results = await runRowsInBrowser(page, FIXTURE); + expect(results).toHaveLength(FIXTURE.rows.length); + for (const result of results) { + expect(result.matched, `row ${result.row}: ${result.description}`).toBe( + result.expectedMatched + ); + } + }); + + test('loaded the full 8-row cross-SDK fixture, matching the Node/Mocha unit runner (AC1 completeness guard)', () => { + expect(FIXTURE.rows).toHaveLength(8); + expect(FIXTURE.rows.filter((row) => row.expectedMatched)).toHaveLength(4); + expect(FIXTURE.rows.filter((row) => row.expectWarn)).toHaveLength(2); + }); +}); diff --git a/packages/js-sdk/tests/integration/mutual-exclusion-rule.spec.ts b/packages/js-sdk/tests/integration/mutual-exclusion-rule.spec.ts new file mode 100644 index 00000000..c34e8b0d --- /dev/null +++ b/packages/js-sdk/tests/integration/mutual-exclusion-rule.spec.ts @@ -0,0 +1,399 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * SDK-3 (RED) -- qs-03 mutual-exclusion audience rule (`bucketed_into_experience_key`), + * end-to-end through the public Context API against the real built CJS bundle. + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md + * AC2 (end-to-end exclusion), AC3 (DataStore persistence), AC4 (no new inputs), AC5 + * (read-only), AC6 (ALL/ANY combination semantics). + * + * Mirrors full-chain.spec.ts's structure (imports the built `lib/index` the same way a + * real consumer would, plain-object config, `sdk.onReady()` + `sdk.createContext()`). + * Unlike full-chain.spec.ts this suite does not need the staging "static"/"live" dual + * mode -- every case constructs its own minimal two-experience `data` config in-process + * (`buildConfig()` below), since the scenarios under test (exclusion audiences, ALL/ANY + * combination) don't exist in the shared staging project. + * + * RED-phase note: `data-manager.ts` has no resolution for `bucketed_into_experience_key` + * yet, so every audience carrying this rule fails closed (see the unit-level fixture + * suite's docstring in packages/data/tests/mutual-exclusion-rule.tests.ts for the exact + * mechanism). Concretely, today: AC2/AC3's "excluded" assertions fail because exp-b + * already returns `null` for EVERY visitor (the audience never matches, regardless of + * negation), including the fresh visitor who should bucket normally; AC6's ALL/ANY + * assertions fail for the same reason. This file is authored in the RED phase only -- + * no production code changed. + */ +import {test, expect} from '@playwright/test'; + +// Import from the built CJS bundle -- same as consumers would use. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const SDK = require('../../lib/index'); +const ConvertSDK = SDK.default; +const {SystemEvents} = SDK; + +const EXP_A_KEY = 'exp-a'; +const EXP_A_ID = '100111'; +const EXP_A_VARIATION_ID = '100901'; +const EXP_B_KEY = 'exp-b'; +const EXP_B_ID = '100222'; +const EXP_B_VARIATION_ID = '100902'; +const EXCLUSION_AUDIENCE_ID = 'qs-03-exclusion-audience'; +const GENERIC_AUDIENCE_ID = 'qs-03-generic-audience'; +const GENERIC_KEY = 'plan'; +const GENERIC_MATCH_VALUE = 'pro'; + +// AC4: driven with an empty visitorProperties object throughout (plus +// ignoreLocationProperties, since these experiences carry no location rules). +const RUN_ATTRS = {visitorProperties: {}, ignoreLocationProperties: true}; + +function makeExclusionAudience(negated: boolean) { + return { + id: EXCLUSION_AUDIENCE_ID, + key: EXCLUSION_AUDIENCE_ID, + type: 'transient', + status: 'active', + rules: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'bucketed_into_experience_key', + matching: {match_type: 'equals', negated}, + value: EXP_A_KEY + } + ] + } + ] + } + ] + } + }; +} + +function makeGenericAudience() { + return { + id: GENERIC_AUDIENCE_ID, + key: GENERIC_AUDIENCE_ID, + type: 'transient', + status: 'active', + rules: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'generic_key_value', + matching: {match_type: 'matches', negated: false}, + key: GENERIC_KEY, + value: GENERIC_MATCH_VALUE + } + ] + } + ] + } + ] + } + }; +} + +// Builds a minimal two-experience config: exp-a carries no audience restriction; exp-b +// carries whichever audiences/matching_options the case under test needs. Hoisted as a +// single builder (per this repo's SonarCloud `new_duplicated_lines_density <= 3%` rule) +// so no case hand-assembles the experience/variation shape. +function buildConfig({ + expBAudienceIds = [], + matchingOptions = 'all', + audiences = [] +}: { + expBAudienceIds?: string[]; + matchingOptions?: 'all' | 'any'; + audiences?: Array>; +} = {}) { + return { + account_id: 'qs-03-account', + project: {id: 'qs-03-project'}, + experiences: [ + { + id: EXP_A_ID, + name: 'exp-a', + key: EXP_A_KEY, + type: 'a/b_fullstack', + status: 'active', + audiences: [], + goals: [], + variations: [ + { + id: EXP_A_VARIATION_ID, + name: 'exp-a variation', + status: 'running', + is_baseline: true + } + ] + }, + { + id: EXP_B_ID, + name: 'exp-b', + key: EXP_B_KEY, + type: 'a/b_fullstack', + status: 'active', + audiences: expBAudienceIds, + goals: [], + settings: {matching_options: {audiences: matchingOptions}}, + variations: [ + { + id: EXP_B_VARIATION_ID, + name: 'exp-b variation', + status: 'running', + is_baseline: true + } + ] + } + ], + audiences + }; +} + +// Minimal in-memory DataStore double -- mirrors full-chain.spec.ts's MemoryDataStore, +// with a call counter (mirrors preview-zero-trace.spec.ts's convention) so AC5's +// read-only claim can be measured directly. +class MemoryDataStore { + data: Record = {}; + setCallCount = 0; + get(key: string): any { + if (!key) return this.data; + return this.data[key.toString()]; + } + set(key: string, value: any): void { + this.setCallCount++; + if (!key) throw new Error('Invalid DataStore key!'); + this.data[key.toString()] = value; + } +} + +function createSdk( + config: Record, + overrides: Record = {} +) { + return new ConvertSDK({ + data: config, + network: {tracking: false}, + ...overrides + }); +} + +async function createReadyContext( + config: Record, + visitorId: string, + overrides: Record = {} +) { + const sdk = createSdk(config, overrides); + await sdk.onReady(); + const context = sdk.createContext(visitorId, {}); // AC4: no visitorAttributes + return {sdk, context}; +} + +// Records every SystemEvents.BUCKETING fire (by experienceKey) for the lifetime of an +// sdk instance -- used by AC5 to prove the exclusion evaluation never buckets its +// target as a side effect. MUST be registered before the FIRST bucketing event of the +// test fires: EventManager replays the earliest `deferred: true` firing of an event to +// any listener registered afterwards (packages/event/src/event-manager.ts `on()`), so a +// listener attached mid-test would spuriously "see" an earlier, unrelated bucketing. +function recordBucketingEvents(sdk: any): Array<{experienceKey: string}> { + const events: Array<{experienceKey: string}> = []; + sdk.on(SystemEvents.BUCKETING, (args: {experienceKey: string}) => + events.push(args) + ); + return events; +} + +test.describe('Mutual-exclusion audience rule end-to-end (qs-03 / SDK-3)', () => { + test('AC2: fresh visitor bucketed into A is excluded from B; a different visitor who never ran A buckets into B normally', async () => { + const config = buildConfig({ + expBAudienceIds: [EXCLUSION_AUDIENCE_ID], + matchingOptions: 'all', + audiences: [makeExclusionAudience(true)] + }); + + const {context: excludedContext} = await createReadyContext( + config, + 'visitor-ac2-excluded' + ); + const decisionA = excludedContext.runExperience(EXP_A_KEY, RUN_ATTRS); + expect(decisionA).toBeTruthy(); + expect(decisionA.id).toBe(EXP_A_VARIATION_ID); + + const decisionB = excludedContext.runExperience(EXP_B_KEY, RUN_ATTRS); + expect(decisionB).toBeNull(); + + const {context: freshContext} = await createReadyContext( + config, + 'visitor-ac2-fresh-never-ran-a' + ); + const decisionBFresh = freshContext.runExperience(EXP_B_KEY, RUN_ATTRS); + expect(decisionBFresh).toBeTruthy(); + expect(decisionBFresh.id).toBe(EXP_B_VARIATION_ID); + }); + + test('AC3: a DataStore-backed decision from one SDK instance excludes the visitor from B in a brand-new instance/context (fixture row 8, end-to-end)', async () => { + const config = buildConfig({ + expBAudienceIds: [EXCLUSION_AUDIENCE_ID], + matchingOptions: 'all', + audiences: [makeExclusionAudience(true)] + }); + const dataStore = new MemoryDataStore(); + const visitorId = 'visitor-ac3-datastore'; + + const {context: firstContext} = await createReadyContext( + config, + visitorId, + {dataStore} + ); + const decisionA = firstContext.runExperience(EXP_A_KEY, RUN_ATTRS); + expect(decisionA).toBeTruthy(); + // DataManager defaults to asyncStorage=true, so the DataStore write is queued + // rather than durable immediately. Flush it explicitly before constructing the + // second instance/context so the shared DataStore genuinely has the persisted + // decision to read -- this is what AC3 actually tests. + await firstContext.releaseQueues(); + + // A brand-new SDK instance/context, sharing only the DataStore. + const {context: secondContext} = await createReadyContext( + config, + visitorId, + {dataStore} + ); + const decisionB = secondContext.runExperience(EXP_B_KEY, RUN_ATTRS); + expect(decisionB).toBeNull(); + + // Control: a THIRD instance/context, sharing the same DataStore but a visitor who + // never ran A, must still bucket into B normally -- proves the exclusion is keyed + // off the visitor's actual stored decision (via the DataStore), not a blanket + // "exp-b never serves" fallback. + const {context: thirdContext} = await createReadyContext( + config, + 'visitor-ac3-datastore-control-never-ran-a', + {dataStore} + ); + const decisionBControl = thirdContext.runExperience(EXP_B_KEY, RUN_ATTRS); + expect(decisionBControl).toBeTruthy(); + expect(decisionBControl.id).toBe(EXP_B_VARIATION_ID); + }); + + test('AC5: evaluating the exclusion audience never buckets, stores, or tracks the target experience', async () => { + const config = buildConfig({ + expBAudienceIds: [EXCLUSION_AUDIENCE_ID], + matchingOptions: 'all', + audiences: [makeExclusionAudience(true)] + }); + const dataStore = new MemoryDataStore(); + const visitorId = 'visitor-ac5-read-only'; + + const {sdk, context} = await createReadyContext(config, visitorId, { + dataStore + }); + // Registered before ANY bucketing event fires in this test (see + // recordBucketingEvents()'s docstring on why ordering matters here). + const bucketingEvents = recordBucketingEvents(sdk); + + const decisionA = context.runExperience(EXP_A_KEY, RUN_ATTRS); + expect(decisionA).toBeTruthy(); + expect( + bucketingEvents.filter((event) => event.experienceKey === EXP_A_KEY) + ).toHaveLength(1); + + const setCallCountBeforeExclusionCheck = dataStore.setCallCount; + const decisionB = context.runExperience(EXP_B_KEY, RUN_ATTRS); + + expect(decisionB).toBeNull(); + // AC5 -- read-only: evaluating exp-b's exclusion audience must not trigger a + // SECOND bucketing of the target (exp-a) as a side effect, nor bucket exp-b + // itself (it's excluded), nor write to the DataStore. + expect( + bucketingEvents.filter((event) => event.experienceKey === EXP_A_KEY) + ).toHaveLength(1); + expect( + bucketingEvents.filter((event) => event.experienceKey === EXP_B_KEY) + ).toHaveLength(0); + expect(dataStore.setCallCount).toBe(setCallCountBeforeExclusionCheck); + }); + + // AC6 -- combination semantics. Each case is deliberately built so its expectation + // can ONLY be produced by a correctly-resolved exclusion rule (never by the pre-seam + // fail-closed-always-false fallback): cases 1 and 3 require the exclusion rule to + // resolve to `true` (visitor never ran A) for the overall experience to serve, which + // the fail-closed fallback cannot produce. Case 2 is the negative control (exclusion + // genuinely violated) to guard against a false-positive "always true" regression. + interface Ac6Case { + name: string; + matchingOptions: 'all' | 'any'; + visitorId: string; + runAFirst: boolean; + visitorProperties: Record; + expectServed: boolean; + } + + const AC6_CASES: Ac6Case[] = [ + { + name: 'ALL requires BOTH to pass: generic matches AND visitor genuinely excluded (never ran A) -> served', + matchingOptions: 'all', + visitorId: 'visitor-ac6-all-served', + runAFirst: false, + visitorProperties: {[GENERIC_KEY]: GENERIC_MATCH_VALUE}, + expectServed: true + }, + { + name: 'ALL fails when the exclusion half fails: generic matches BUT visitor IS bucketed into A -> not served', + matchingOptions: 'all', + visitorId: 'visitor-ac6-all-not-served', + runAFirst: true, + visitorProperties: {[GENERIC_KEY]: GENERIC_MATCH_VALUE}, + expectServed: false + }, + { + name: 'ANY is satisfied by the exclusion audience alone: generic does not match, visitor never ran A -> served', + matchingOptions: 'any', + visitorId: 'visitor-ac6-any-served-by-exclusion-alone', + runAFirst: false, + visitorProperties: {}, + expectServed: true + } + ]; + + // eslint-disable-next-line mocha/no-setup-in-describe + AC6_CASES.forEach((ac6Case) => { + test(`AC6: ${ac6Case.name}`, async () => { + const config = buildConfig({ + expBAudienceIds: [GENERIC_AUDIENCE_ID, EXCLUSION_AUDIENCE_ID], + matchingOptions: ac6Case.matchingOptions, + audiences: [makeGenericAudience(), makeExclusionAudience(true)] + }); + const {context} = await createReadyContext(config, ac6Case.visitorId); + + if (ac6Case.runAFirst) { + const decisionA = context.runExperience(EXP_A_KEY, RUN_ATTRS); + expect(decisionA).toBeTruthy(); + } + + const result = context.runExperience(EXP_B_KEY, { + visitorProperties: ac6Case.visitorProperties, + ignoreLocationProperties: true + }); + + if (ac6Case.expectServed) { + expect(result).toBeTruthy(); + expect(result.id).toBe(EXP_B_VARIATION_ID); + } else { + expect(result).toBeNull(); + } + }); + }); +}); diff --git a/packages/rules/tests/bucketed-into-experience-key-compatibility.tests.ts b/packages/rules/tests/bucketed-into-experience-key-compatibility.tests.ts new file mode 100644 index 00000000..1dbf63ca --- /dev/null +++ b/packages/rules/tests/bucketed-into-experience-key-compatibility.tests.ts @@ -0,0 +1,138 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * qs-03 (SDK-4) — regression lock for the RuleManager compatibility contract when it receives + * the new `bucketed_into_experience_key` audience rule type. + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md + * Compatibility section (lines 96-98) + AC7 (line 89). + * + * `packages/rules/src/rule-manager.ts` is intentionally left BYTE-FOR-BYTE UNCHANGED by qs-03 — + * the new rule type is resolved entirely in `packages/data`'s DataManager, which reads stored + * bucketing state before the tree ever reaches RuleManager. This spec locks the documented + * fall-through contract an old SDK version / a standalone RuleManager exhibits when the rule + * still reaches it: with `visitorProperties = {}`, `_processRuleItem` never recognizes + * `rule_type: 'bucketed_into_experience_key'` in literal-object mode, so every row falls through + * to the final `return false` with `matching.negated` UNAPPLIED (fail-closed, no negation flip) — + * regardless of the row's `negated` flag or its DataManager-resolved `expectedMatched` value. + * The DataManager-resolved contract (where this rule actually works) is asserted separately in + * packages/data/tests. + * + * Fixture home: packages/data/tests/mutual-exclusion-rule-fixture.json (owned there — its + * `config.experiences` blocks and DataManager-facing shape are data-manager concerns). Read here + * via a relative filesystem path, mirroring packages/data/tests/cross-sdk-vectors.tests.ts's read + * of the bucketing package's fixture — no new build-graph edge between packages/rules and + * packages/data is introduced. + */ +import 'mocha'; +import {expect} from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import {RuleManager as rm} from '../src/rule-manager'; + +interface MutualExclusionFixtureRow { + row: number; + description: string; + negated: boolean; + ruleValue: string; +} + +interface MutualExclusionFixture { + rows: MutualExclusionFixtureRow[]; +} + +// Hoisted once: shared RuleManager instance + fixture read, per this repo's SonarCloud +// new_duplicated_lines_density <= 3% rule (no per-case setup/teardown, single parameterized loop). +const FIXTURE_PATH = path.resolve( + __dirname, + '../../data/tests/mutual-exclusion-rule-fixture.json' +); +const FIXTURE: MutualExclusionFixture = JSON.parse( + fs.readFileSync(FIXTURE_PATH, 'utf8') +); + +// Untyped (implicit `any`), matching rule-manager.tests.ts's own `let ruleManager;` -- the +// fullstack literal-mode rule trees this suite builds (both the `key`-addressed generic vector +// and the `rule_type`-addressed mutual-exclusion rule below) are a fullstack-SDK-only shape and +// are not, and were never meant to be, members of the auto-generated `RuleElement` union in +// packages/types/src/config/ (generated from the OpenAPI spec, never hand-edited). Declaring +// `ruleManager` with its strict `RuleManager` type would force every literal `isRuleMatched` +// call site to fight that generated union instead of exercising the literal-object mode itself. +const ruleManager: any = new rm(); + +/** + * Builds the same OR -> AND -> OR_WHEN audience tree shape existing rules tests use (see + * rule-manager.tests.ts's testRuleSet1/2/3), with a single `bucketed_into_experience_key` + * rule item as the leaf, exactly as it would be served in a config audience. + */ +function buildMutualExclusionAudienceTree(row: MutualExclusionFixtureRow) { + return { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'bucketed_into_experience_key', + matching: { + match_type: 'equals', + negated: row.negated + }, + value: row.ruleValue + } + ] + } + ] + } + ] + }; +} + +describe('RuleManager compatibility contract for bucketed_into_experience_key (qs-03 SDK-4)', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + FIXTURE.rows.forEach((row) => { + it(`row ${row.row} (negated=${row.negated}, value=${row.ruleValue}): fail-closed fall-through, negation unapplied -- ${row.description}`, function () { + const tree = buildMutualExclusionAudienceTree(row); + expect(ruleManager.isRuleMatched({}, tree)).to.equal(false); + }); + }); + + it('AC7 -- generic key/value rule matching is unchanged by the unmodified RuleManager (representative vector)', function () { + // The 3 generic key/value rule types are exhaustively covered by rule-manager.tests.ts + // already (equals/less/isIn/isTypeOf/exists/doesNotExist, AND/OR/OR_WHEN combination, + // negation). This is a targeted, non-duplicating statement that the qs-03 resolution seam + // -- which lives entirely in DataManager -- left this generic literal-object code path + // bit-identical: a plain `key`-addressed rule still matches/negates exactly as before. + const genericRuleSet = { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + key: 'device', + matching: { + match_type: 'equals', + negated: false + }, + value: 'pc' + } + ] + } + ] + } + ] + }; + expect(ruleManager.isRuleMatched({device: 'pc'}, genericRuleSet)).to.equal( + true + ); + expect( + ruleManager.isRuleMatched({device: 'phone'}, genericRuleSet) + ).to.equal(false); + }); +});