mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Compare strings by UTF-16 code unit in the rules engine A JS string is a sequence of UTF-16 code units and every string operation works on those units. Swift's String compares canonical equivalence over Unicode scalars, so <, <=, >, >=, ==, ===, in and rc.entries key ordering all had cases where a predicate evaluated one way on iOS and the other in JS and Kotlin. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the new fixture descriptions platform-neutral The fixture files are byte-identical across the iOS and Android engines, so a description that explains the contract in terms of one host language's string comparison does not read correctly in the other repository. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the characters under test in the UTF-16 fixture descriptions The predicates spell these strings as \uXXXX escapes so the exact code units are unambiguous, which leaves a reader unable to tell which character a fixture is about. Each description now shows the glyph and its code point, and spells out the surrogate pair where one is the point of the case. Co-authored-by: Cursor <cursoragent@cursor.com> * Route rc.sortBy string keys through jsStringPrecedes The operator open-coded the UTF-16 comparison because the shared helper did not exist yet when it landed. It does now, and the rationale lives in the helper's doc, so the operator no longer repeats it. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare object keys by UTF-16 code unit A Swift dictionary keys by canonical equivalence, so é (U+00E9) and e followed by a combining acute (U+0301) were one key: an object holding both spellings kept only one, and a var path spelled either way resolved against the other. JS and Kotlin keep the two apart, so a rule reading a non-ASCII key could answer differently per platform. Value.object now stores an ObjectValue, whose keys compare and hash by code unit, and the JSON parser iterates the NSDictionary rather than casting to [String: Any], which merges the keys before the engine sees them. Co-authored-by: Cursor <cursoragent@cursor.com> * Fold the ObjectValue.filter condition into a where clause SwiftLint's for_where rule, and it reads better. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
8.5 KiB
JSON
189 lines
8.5 KiB
JSON
{
|
||
"fixtures": [
|
||
{
|
||
"id": "entries_object_pairs_sorted_lexicographically_by_key",
|
||
"description": "Object keys are emitted in lexicographic order regardless of declaration order; pair shape and order are verified via JS string coercion of the nested pair arrays",
|
||
"predicate": {"==": [{"rc.entries": {"c": 3, "a": 1, "b": 2}}, "a,1,b,2,c,3"]},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "entries_object_pair_shape_exposes_key_and_value_scalars",
|
||
"description": "Each pair is a two-element array; some finds the expected key and value without comparing compound values directly",
|
||
"predicate": {
|
||
"and": [
|
||
{"some": [{"rc.entries": {"z": 9, "a": 1}}, {"===": [{"var": "0"}, "a"]}]},
|
||
{"some": [{"rc.entries": {"z": 9, "a": 1}}, {"===": [{"var": "1"}, 1]}]}
|
||
]
|
||
},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "entries_array_produces_string_index_keys",
|
||
"description": "Array input yields [[\"0\", elem0], [\"1\", elem1], ...]; literal array operands must be wrapped ([[...]]) per json-logic-js spread semantics",
|
||
"predicate": {"==": [{"rc.entries": [["a", "b"]]}, "0,a,1,b"]},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "entries_multi_element_arg_list_uses_first_only",
|
||
"description": "json-logic-js spreads the arg array into the operator; {\"rc.entries\": [\"a\", \"b\"]} uses only the first argument (\"a\", a string → type error), extra args silently ignored like ! and !!. Treating the list as one array operand would instead succeed",
|
||
"predicate": {"rc.entries": ["a", "b"]},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.entries"}
|
||
},
|
||
{
|
||
"id": "entries_singleton_wrapped_object_form",
|
||
"description": "Single-arg list wrapping ({\"rc.entries\": [obj]}) is equivalent to the bare object form",
|
||
"predicate": {"==": [{"rc.entries": [{"b": 2, "a": 1}]}, "a,1,b,2"]},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "entries_null_is_type_error",
|
||
"predicate": {"rc.entries": null},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.entries"}
|
||
},
|
||
{
|
||
"id": "entries_undefined_is_type_error",
|
||
"predicate": {"rc.entries": {"and": []}},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.entries"}
|
||
},
|
||
{
|
||
"id": "entries_bool_is_type_error",
|
||
"predicate": {"rc.entries": true},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.entries"}
|
||
},
|
||
{
|
||
"id": "entries_number_is_type_error",
|
||
"predicate": {"rc.entries": 42},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.entries"}
|
||
},
|
||
{
|
||
"id": "entries_string_is_type_error",
|
||
"description": "Deliberate divergence from JS Object.entries(\"ab\"), which yields character pairs; a string is a type error so a misaimed rule fails loudly instead of iterating characters",
|
||
"predicate": {"rc.entries": "ab"},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.entries"}
|
||
},
|
||
{
|
||
"id": "fromEntries_round_trips_object_through_entries",
|
||
"description": "rc.fromEntries(rc.entries(obj)) reproduces the object; verified by re-stringifying entries because compound equality is always false",
|
||
"predicate": {"==": [{"rc.entries": {"rc.fromEntries": {"rc.entries": {"b": 2, "a": 1}}}}, "a,1,b,2"]},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "fromEntries_duplicate_keys_last_wins",
|
||
"description": "Later pairs overwrite earlier ones for the same key; verified via some on the round-tripped entries",
|
||
"predicate": {
|
||
"some": [
|
||
{"rc.entries": {"rc.fromEntries": [[["a", 1], ["a", 2], ["b", 3]]]}},
|
||
{"and": [{"===": [{"var": "0"}, "a"]}, {"===": [{"var": "1"}, 2]}]}
|
||
]
|
||
},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "fromEntries_malformed_entry_is_type_error",
|
||
"description": "A non-array entry is a type error rather than being skipped, so a partial object can never pass for a legitimate result",
|
||
"predicate": {"rc.fromEntries": [[["a", 1], "not-an-array", ["b", 2]]]},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.fromEntries"}
|
||
},
|
||
{
|
||
"id": "fromEntries_entry_missing_value_is_type_error",
|
||
"description": "A one-element entry has no value to bind; it throws instead of defaulting to null",
|
||
"predicate": {"rc.fromEntries": [[["a"]]]},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.fromEntries"}
|
||
},
|
||
{
|
||
"id": "fromEntries_entry_with_extra_element_is_type_error",
|
||
"description": "Deliberate divergence from JS Object.fromEntries, which ignores elements past the second; an over-long entry is ambiguous so it throws",
|
||
"predicate": {"rc.fromEntries": [[["a", 1, 2]]]},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.fromEntries"}
|
||
},
|
||
{
|
||
"id": "fromEntries_non_array_argument_is_type_error",
|
||
"predicate": {"rc.fromEntries": null},
|
||
"variables": {},
|
||
"expected": {"error": "typeMismatch", "operator": "rc.fromEntries"}
|
||
},
|
||
{
|
||
"id": "fromEntries_empty_array_returns_empty_object",
|
||
"description": "An empty pair list is a single wrapped operand: {\"rc.fromEntries\": [[]]}",
|
||
"predicate": {
|
||
"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 0]
|
||
},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "entries_end_to_end_filter_keyed_object_and_rebuild",
|
||
"description": "Motivating case: filter subscriptions by status on the value (var 1) while keeping keys (var 0), then rebuild the keyed object",
|
||
"predicate": {
|
||
"===": [
|
||
{
|
||
"reduce": [
|
||
{
|
||
"rc.entries": {
|
||
"rc.fromEntries": {
|
||
"filter": [{"rc.entries": {"var": "subscriptions"}}, {"===": [{"var": "1.status"}, "active"]}]
|
||
}
|
||
}
|
||
},
|
||
{"+": [{"var": "accumulator"}, 1]},
|
||
0
|
||
]
|
||
},
|
||
2
|
||
]
|
||
},
|
||
"variables": {
|
||
"subscriptions": {
|
||
"prod_a": {"status": "active", "price": 10},
|
||
"prod_b": {"status": "expired", "price": 5},
|
||
"prod_c": {"status": "active", "price": 15}
|
||
}
|
||
},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "entries_keys_sort_by_utf16_code_unit",
|
||
"description": "The keys are fullwidth capital A A (U+FF21) and the grinning face emoji 😀 (U+1F600, encoded as the surrogate pair U+D83D U+DE00). Keys sort as UTF-16 code units, so the emoji's leading surrogate U+D83D places it ahead of U+FF21, even though its code point is the higher of the two",
|
||
"predicate": {"some": [[{"rc.entries": [{"var": "o"}]}], {"and": [{"===": [{"var": "0.0"}, "\ud83d\ude00"]}, {"===": [{"var": "1.0"}, "\uff21"]}]}]},
|
||
"variables": {"o": {"\uff21": 1, "\ud83d\ude00": 2}},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "from_entries_keeps_two_spellings_of_the_same_character_apart",
|
||
"description": "One key is é (U+00E9), the other is é spelled e (U+0065) followed by a combining acute accent (U+0301). They render alike but are different UTF-16 code unit sequences, so the object has two entries",
|
||
"predicate": {"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["e\u0301", 2]]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 2]},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "from_entries_pairs_each_spelling_with_its_own_value",
|
||
"description": "The decomposed spelling é (U+0065 U+0301) keeps its own value instead of being folded into the precomposed é (U+00E9)",
|
||
"predicate": {"some": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["e\u0301", 2]]]}}, {"and": [{"===": [{"var": "0"}, "e\u0301"]}, {"===": [{"var": "1"}, 2]}]}]},
|
||
"variables": {},
|
||
"expected": true
|
||
},
|
||
{
|
||
"id": "from_entries_still_merges_a_repeated_identical_key",
|
||
"description": "Two entries spelled with the same code units are one key, the last value winning, as in JS",
|
||
"predicate": {"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["\u00e9", 2]]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 1]},
|
||
"variables": {},
|
||
"expected": true
|
||
}
|
||
]
|
||
}
|