From 2570343605c2cdab38a2cc9e7003114492af06fa Mon Sep 17 00:00:00 2001 From: Marvin Frachet Date: Tue, 15 Oct 2024 10:00:53 +0200 Subject: [PATCH 1/2] chore(tests): add tests for comparators --- .../__tests__/isEligibleForStrategy.test.ts | 185 ++++++++++++++++++ packages/core/src/getEligibleStrategy.ts | 21 +- packages/core/src/types.ts | 11 +- 3 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/__tests__/isEligibleForStrategy.test.ts diff --git a/packages/core/src/__tests__/isEligibleForStrategy.test.ts b/packages/core/src/__tests__/isEligibleForStrategy.test.ts new file mode 100644 index 0000000..9eeaef1 --- /dev/null +++ b/packages/core/src/__tests__/isEligibleForStrategy.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { isEligibleForStrategy } from "../getEligibleStrategy"; +import { Rule, UserConfiguration } from "../types"; + +describe("isEligibleForStrategy", () => { + it.each([ + [1, 1, true], + [1, "1", false], + [null, null, true], + [undefined, undefined, true], + [NaN, NaN, false], + [0, -0, true], + [0, 0, true], + ["hello", "hello", true], + ["hello", "world", false], + [true, true, true], + [true, false, false], + [false, false, true], + [{}, {}, false], + [[], [], false], + [[1, 2], [1, 2], false], + [42, 42, true], + [42, "42", false], + [{ key: "value" }, { key: "value" }, false], + [new Date(2024, 0, 1), new Date(2024, 0, 1), false], + [undefined, null, false], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator equals", + (fieldValue, country, expected) => { + const rules: Rule[] = [ + { + operator: "equals", + field: "country", + value: fieldValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: country as string, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); + + it.each([ + [1, 1, false], + [1, "1", true], + [null, null, false], + [undefined, undefined, false], + [NaN, NaN, true], + [0, -0, false], + [0, 0, false], + ["hello", "hello", false], + ["hello", "world", true], + [true, true, false], + [true, false, true], + [false, false, false], + [{}, {}, true], + [[], [], true], + [[1, 2], [1, 2], true], + [42, 42, false], + [42, "42", true], + [{ key: "value" }, { key: "value" }, true], + [new Date(2024, 0, 1), new Date(2024, 0, 1), true], + [undefined, null, true], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator not_equals", + (fieldValue, country, expected) => { + const rules: Rule[] = [ + { + operator: "not_equals", + field: "country", + value: fieldValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: country as string, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); + + it.each([ + [2, 1, true], + [1, 2, false], + [5, 5, false], + [0, -1, true], + [-1, 0, false], + [3.5, 2.5, true], + ["b", "a", true], + ["a", "b", false], + ["apple", "Apple", true], + [true, false, true], + [false, true, false], + [10, "5", true], // numeric comparison + ["10", 5, true], // lexicographic comparison + [undefined, 0, false], + [null, 0, false], + [null, -1, false], + [undefined, undefined, false], + [NaN, 0, false], + [3, NaN, false], + ["z", "a", true], + ["hello", "world", false], + [100, 99, true], + ["100", "99", false], + [new Date("2024-01-01"), new Date("2023-12-31"), true], + [new Date("2023-12-31"), new Date("2024-01-01"), false], + [new Date("2024-01-01"), new Date("2024-01-01"), false], + [new Date("2023-06-15"), new Date("2023-06-14"), true], + [new Date("2022-06-15"), new Date("2023-06-15"), false], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator greater_than", + (userValue, ruleValue, expected) => { + const rules: Rule[] = [ + { + operator: "greater_than", + field: "country", + value: ruleValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: userValue, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); + + it.each([ + [1, 2, true], + [2, 1, false], + [5, 5, false], + [-1, 0, true], + [0, -1, false], + [2.5, 3.5, true], + ["a", "b", true], + ["b", "a", false], + ["Apple", "apple", true], + [false, true, true], + [true, false, false], + ["5", 10, true], // lexicographic comparison + [5, "10", true], // numeric comparison + [null, 0, false], + [0, null, false], + [undefined, null, false], + [0, undefined, false], + [NaN, 0, false], + [0, NaN, false], + ["a", "z", true], + ["world", "hello", false], + [99, 100, true], + ["99", "100", false], + [new Date("2023-12-31"), new Date("2024-01-01"), true], + [new Date("2024-01-01"), new Date("2023-12-31"), false], + [new Date("2024-01-01"), new Date("2024-01-01"), false], + [new Date("2023-06-14"), new Date("2023-06-15"), true], + [new Date("2023-06-15"), new Date("2022-06-15"), false], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator less_than", + (userValue, ruleValue, expected) => { + const rules: Rule[] = [ + { + operator: "less_than", + field: "country", + value: ruleValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: userValue, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); +}); diff --git a/packages/core/src/getEligibleStrategy.ts b/packages/core/src/getEligibleStrategy.ts index 29724eb..ec5c26c 100644 --- a/packages/core/src/getEligibleStrategy.ts +++ b/packages/core/src/getEligibleStrategy.ts @@ -3,7 +3,10 @@ import { FlagConfiguration, Rule, UserConfiguration } from "./types"; const isNumber = (value: unknown): value is number => typeof value === "number"; const isString = (value: unknown): value is string => typeof value === "string"; -const isEligibleForStrategy = ( +const isUndefined = (value: unknown): value is undefined => + typeof value === "undefined" || value === null; + +export const isEligibleForStrategy = ( rules: Rule[], userConfiguration: UserConfiguration ): boolean => { @@ -24,21 +27,17 @@ const isEligibleForStrategy = ( case "greater_than": { const fieldValue = userConfiguration[rule.field]; - return ( - isNumber(fieldValue) && - isNumber(rule.value) && - fieldValue > rule.value - ); + if (isUndefined(fieldValue) || isUndefined(rule.value)) return false; + + return fieldValue! > rule.value!; } case "less_than": { const fieldValue = userConfiguration[rule.field]; - return ( - isNumber(fieldValue) && - isNumber(rule.value) && - fieldValue < rule.value - ); + if (isUndefined(fieldValue) || isUndefined(rule.value)) return false; + + return fieldValue! < rule.value!; } case "contains": { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c28ab11..934356e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,11 +8,16 @@ export type ConditionOperator = | "less_than" | "in"; -export type RuleValue = +type RuleValuePrimitive = + | object + | null + | undefined | string | number | boolean - | Array; + | Date; + +export type RuleValue = RuleValuePrimitive | Array; export type Rule = | { @@ -48,7 +53,7 @@ export type FlagConfiguration = { export type UserConfiguration = { __id: string; - [key: string]: string | number | boolean; + [key: string]: RuleValuePrimitive; }; export type FlagsConfiguration = Array; From 026cc8c7514742a4ee441763e1702a292d1892a1 Mon Sep 17 00:00:00 2001 From: Marvin Frachet Date: Tue, 15 Oct 2024 14:05:03 +0200 Subject: [PATCH 2/2] chore(tests): add tests for comparators --- packages/core/package.json | 2 +- .../__tests__/isEligibleForStrategy.test.ts | 148 ++++++++++++++++++ packages/core/src/getEligibleStrategy.ts | 13 +- packages/core/src/types.ts | 3 +- 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 72b8be8..1731c51 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,7 @@ }, "types": "./dist/index.d.ts", "scripts": { - "build": "rollup -c rollup.config.mjs", + "build": "rm -rf dist .turbo && rollup -c rollup.config.mjs", "start": "tsx src/index.ts", "test": "vitest", "coverage": "vitest run --coverage", diff --git a/packages/core/src/__tests__/isEligibleForStrategy.test.ts b/packages/core/src/__tests__/isEligibleForStrategy.test.ts index 9eeaef1..a3d7f47 100644 --- a/packages/core/src/__tests__/isEligibleForStrategy.test.ts +++ b/packages/core/src/__tests__/isEligibleForStrategy.test.ts @@ -182,4 +182,152 @@ describe("isEligibleForStrategy", () => { expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); } ); + + it.each([ + ["hello world", "world", true], + ["hello world", "planet", false], + ["apple pie", "apple", true], + ["apple pie", "pie", true], + ["apple pie", "banana", false], + ["JavaScript", "script", false], // case-sensitive + ["JavaScript", "Script", true], + ["", "", true], // empty string contains an empty string + ["hello", "", true], // any string contains an empty string + ["open source", "open", true], + ["case sensitive", "Case", false], + ["The quick brown fox", "fox", true], + ["The quick brown fox", "dog", false], + ["coding", "code", false], + ["coding", "ing", true], + ["Paris", "is", true], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator contains", + (userValue, ruleValue, expected) => { + const rules: Rule[] = [ + { + operator: "contains", + field: "country", + value: ruleValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: userValue, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); + + it.each([ + ["hello world", "planet", true], + ["hello world", "world", false], + ["apple pie", "banana", true], + ["apple pie", "apple", false], + ["JavaScript", "script", true], // case-sensitive + ["JavaScript", "Script", false], + ["", "hello", true], // empty string does not contain any non-empty string + ["hello", "", false], // any string contains an empty string + ["open source", "closed", true], + ["case sensitive", "Case", true], + ["The quick brown fox", "dog", true], + ["The quick brown fox", "fox", false], + ["coding", "code", true], + ["coding", "ing", false], + ["Paris", "London", true], + ["Paris", "is", false], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator not_contains", + (userValue, ruleValue, expected) => { + const rules: Rule[] = [ + { + operator: "not_contains", + field: "country", + value: ruleValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: userValue, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); + + it.each([ + [2, [1, 2, 3], true], + [4, [1, 2, 3], false], + ["banana", ["apple", "banana", "cherry"], true], + ["orange", ["apple", "banana", "cherry"], false], + ["c", ["a", "b", "c"], true], + ["d", ["a", "b", "c"], false], + [null, [null, undefined, NaN], true], + [0, [null, undefined, NaN], false], + [false, [true, false, true], true], + [false, [true, true, true], false], + [1, [], false], // empty array contains nothing + [{ key: "value" }, [1, 2, { key: "value" }], false], // references are different + [123, ["string", 123, true], true], + ["123", ["string", 123, true], false], // strict comparison + ["open", ["open", "source"], true], + ["closed", ["open", "source"], false], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator in", + (userValue, ruleValue, expected) => { + const rules: Rule[] = [ + { + operator: "in", + field: "country", + value: ruleValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: userValue, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); + + it.each([ + [2, [1, 2, 3], false], + [4, [1, 2, 3], true], + ["banana", ["apple", "banana", "cherry"], false], + ["orange", ["apple", "banana", "cherry"], true], + ["c", ["a", "b", "c"], false], + ["d", ["a", "b", "c"], true], + [null, [null, undefined, NaN], false], + [0, [null, undefined, NaN], true], + [false, [true, false, true], false], + [false, [true, true, true], true], + [1, [], true], // empty array contains nothing + [{ key: "value" }, [1, 2, { key: "value" }], true], // references are different + [123, ["string", 123, true], false], + ["123", ["string", 123, true], true], // strict comparison + ["open", ["open", "source"], false], + ["closed", ["open", "source"], true], + ])( + "when field is '%s' and country is '%s', then it should return '%s' for operator not_in", + (userValue, ruleValue, expected) => { + const rules: Rule[] = [ + { + operator: "not_in", + field: "country", + value: ruleValue, + }, + ]; + + const userConfiguration: UserConfiguration = { + country: userValue, + __id: "yo", + }; + + expect(isEligibleForStrategy(rules, userConfiguration)).toBe(expected); + } + ); }); diff --git a/packages/core/src/getEligibleStrategy.ts b/packages/core/src/getEligibleStrategy.ts index ec5c26c..9517034 100644 --- a/packages/core/src/getEligibleStrategy.ts +++ b/packages/core/src/getEligibleStrategy.ts @@ -1,6 +1,5 @@ import { FlagConfiguration, Rule, UserConfiguration } from "./types"; -const isNumber = (value: unknown): value is number => typeof value === "number"; const isString = (value: unknown): value is string => typeof value === "string"; const isUndefined = (value: unknown): value is undefined => @@ -63,7 +62,17 @@ export const isEligibleForStrategy = ( case "in": { const fieldValue = userConfiguration[rule.field]; - return Array.isArray(rule.value) && rule.value.includes(fieldValue); + return ( + Array.isArray(rule.value) && rule.value.indexOf(fieldValue) !== -1 + ); + } + + case "not_in": { + const fieldValue = userConfiguration[rule.field]; + + return ( + Array.isArray(rule.value) && rule.value.indexOf(fieldValue) === -1 + ); } default: diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 934356e..3ddf5bf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6,7 +6,8 @@ export type ConditionOperator = | "not_contains" | "greater_than" | "less_than" - | "in"; + | "in" + | "not_in"; type RuleValuePrimitive = | object