Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import fc from 'fast-check';
2import _ from 'lodash';
3
4import { ConditionCompletionOutcome } from '../models/rules/RuleModel.js';
5import {
6 ConditionConjunction,
7 type LeafCondition,
8} from '../services/moderationConfigService/index.js';
9import {
10 getSignalIdString,
11 type ExternalSignalId,
12 type SignalId,
13} from '../services/signalsService/index.js';
14import {
15 ConditionOutcomeArbitrary as anyOutcomeArbitrary,
16 ConditionConjunctionArbitrary,
17 FalseyCompletionOutcomeArbitrary as falseyOutcomeArbitrary,
18 LeafConditionArbitrary,
19 makeConditionInputArbitrary,
20 NullLikeConditionCompletionOutcomeArbitrary as nullOutcomeArbitrary,
21 TruthyConditionCompletionOutcomeArbitrary as truthyOutcomeArbitrary,
22} from '../test/arbitraries/Condition.js';
23import { type NonEmptyArray } from '../utils/typescript-types.js';
24import { getCost, outcomeToNullableBool } from './condition.js';
25import {
26 getConditionSetOutcome,
27 getConditionSetResults,
28 tryGetOutcomeFromPartialOutcomes,
29} from './conditionSet.js';
30import type { ReadonlyDeep } from 'type-fest';
31
32const { sampleSize, shuffle, groupBy } = _;
33const { AND, OR, XOR } = ConditionConjunction;
34
35describe('Condition Evaluation', () => {
36 describe('getConditionSetResults', () => {
37 test('should run conditions in cost order, skipping unnecessary ones', async () => {
38 const stubRunLeafCondition = jest.fn(async (_it: ReadonlyDeep<LeafCondition>) => ({
39 outcome: ConditionCompletionOutcome.PASSED,
40 }));
41
42 await fc.assert(
43 fc
44 .asyncProperty(
45 fc.array(
46 fc.tuple(
47 LeafConditionArbitrary(makeConditionInputArbitrary()),
48 fc.nat(),
49 ),
50 { minLength: 1 },
51 ),
52 async (leafConditionsWithCosts) => {
53 // Take all the generated conditions, and only keep one for each
54 // signal (including for a null signal) so that we can return
55 // sensible/consistent costs across conditions.
56 const leafConditionsAndCostsWithUniqueSignalIds = _.uniqBy(
57 leafConditionsWithCosts,
58 (it) => (it[0].signal ? it[0].signal.id : null),
59 );
60
61 const getSignalCost = (() => {
62 const costsBySignalId = new Map(
63 leafConditionsAndCostsWithUniqueSignalIds
64 .filter((it) => it[0].signal) // this fn only handles signals that are defined
65 .map(
66 ([condition, cost]) =>
67 [condition.signal!.id, cost] as const,
68 ),
69 );
70
71 return async (id: ExternalSignalId) =>
72 costsBySignalId.get(getSignalIdString(id))!;
73 })() satisfies (id: ExternalSignalId) => Promise<number> as (
74 id: SignalId,
75 ) => Promise<number>;
76
77 const conditions = leafConditionsAndCostsWithUniqueSignalIds.map(
78 (it) => it[0],
79 ) satisfies LeafCondition[] as NonEmptyArray<LeafCondition>;
80
81 const lowestConditionCost = Math.min(
82 ...(await Promise.all(
83 conditions.map(async (it) => getCost(it, getSignalCost)),
84 )),
85 );
86
87 await getConditionSetResults(
88 { conditions, conjunction: ConditionConjunction.OR },
89 { getSignalCost } as any,
90 jest.fn() as any,
91 stubRunLeafCondition,
92 );
93
94 // We should've only evaluated one condition (the lowest cost one)
95 // because it will pass, and the condition conjuction is OR, so
96 // the remaining ones can be skipped.
97 expect(stubRunLeafCondition).toHaveBeenCalledTimes(1);
98
99 // We don't know exactly which condition will have been run,
100 // because multiple conditions could be tied for having the lowest
101 // cost, but we assert that whatever condition was evaluated has
102 // the lowest cost.
103 expect(
104 await getCost(
105 stubRunLeafCondition.mock.calls[0][0],
106 getSignalCost,
107 ),
108 ).toEqual(lowestConditionCost);
109 },
110 )
111 .afterEach(() => {
112 stubRunLeafCondition.mockClear();
113 }),
114 );
115 });
116 });
117
118 describe('getConditionSetOutcome', () => {
119 describe('AND', () => {
120 test('should return false if there are any falsey outcomes', () => {
121 fc.assert(
122 fc.property(
123 fc.array(anyOutcomeArbitrary),
124 falseyOutcomeArbitrary,
125 (outcomes, falseyOutcome) => {
126 const withFalseOutcomes = shuffle(outcomes.concat(falseyOutcome));
127 const result = getConditionSetOutcome(withFalseOutcomes, AND);
128 expect(outcomeToNullableBool(result)).toBe(false);
129 },
130 ),
131 );
132 });
133
134 test("should preserve the particular falsey outcome when there's only one", () => {
135 fc.assert(
136 fc.property(
137 fc.array(fc.oneof(truthyOutcomeArbitrary, nullOutcomeArbitrary)),
138 falseyOutcomeArbitrary,
139 (nonFalseyOutcomes, falseyOutcome) => {
140 const withFalseOutcome = shuffle(
141 nonFalseyOutcomes.concat(falseyOutcome),
142 );
143
144 const result = getConditionSetOutcome(withFalseOutcome, AND);
145 expect(result).toBe(falseyOutcome);
146 },
147 ),
148 );
149 });
150
151 test('should return a truthy outcome if there are only truthy outcomes', () => {
152 fc.assert(
153 fc.property(fc.array(truthyOutcomeArbitrary), (outcomes) => {
154 const result = getConditionSetOutcome(outcomes, AND);
155 expect(outcomeToNullableBool(result)).toBe(true);
156 }),
157 );
158 });
159
160 test('should return null for any mix of null-like and truthy outcomes', () => {
161 fc.assert(
162 fc.property(
163 fc.array(truthyOutcomeArbitrary),
164 fc.array(nullOutcomeArbitrary, { minLength: 1 }),
165 (trueOutcomes, nullOutcomes) => {
166 const trueOrNullOutcomes = trueOutcomes.concat(nullOutcomes);
167 const result = getConditionSetOutcome(trueOrNullOutcomes, AND);
168 expect(outcomeToNullableBool(result)).toBe(null);
169 },
170 ),
171 );
172 });
173 });
174
175 describe('OR', () => {
176 test('should return true if there are any truthy outcomes', () => {
177 fc.assert(
178 fc.property(
179 fc.array(anyOutcomeArbitrary),
180 truthyOutcomeArbitrary,
181 (outcomes, truthyOutcome) => {
182 const withTrueOutcomes = shuffle(outcomes.concat(truthyOutcome));
183 const result = getConditionSetOutcome(withTrueOutcomes, OR);
184 expect(outcomeToNullableBool(result)).toBe(true);
185 },
186 ),
187 );
188 });
189
190 test("should preserve the particular truthy outcome when there's only one", () => {
191 fc.assert(
192 fc.property(
193 fc.array(fc.oneof(falseyOutcomeArbitrary, nullOutcomeArbitrary)),
194 truthyOutcomeArbitrary,
195 (nonTruthyOutcomes, truthyOutcome) => {
196 const withTrueOutcome = shuffle(
197 nonTruthyOutcomes.concat(truthyOutcome),
198 );
199
200 const result = getConditionSetOutcome(withTrueOutcome, OR);
201 expect(result).toBe(truthyOutcome);
202 },
203 ),
204 );
205 });
206
207 test('should return a falsey outcome if there are only falsey outcomes', () => {
208 fc.assert(
209 fc.property(fc.array(falseyOutcomeArbitrary), (outcomes) => {
210 const result = getConditionSetOutcome(outcomes, OR);
211 expect(outcomeToNullableBool(result)).toBe(false);
212 }),
213 );
214 });
215
216 test('should return null for any mix of null-like and falsey outcomes', () => {
217 fc.assert(
218 fc.property(
219 fc.array(falseyOutcomeArbitrary),
220 fc.array(nullOutcomeArbitrary, { minLength: 1 }),
221 (falseyOutcomes, nullOutcomes) => {
222 const falseOrNullOutcomes = falseyOutcomes.concat(nullOutcomes);
223 const result = getConditionSetOutcome(falseOrNullOutcomes, OR);
224 expect(outcomeToNullableBool(result)).toBe(null);
225 },
226 ),
227 );
228 });
229 });
230
231 describe('XOR', () => {
232 test("should return true if there's exactly one truthy outcome", () => {
233 fc.assert(
234 fc.property(fc.array(anyOutcomeArbitrary), (outcomes) => {
235 const result = getConditionSetOutcome(outcomes, XOR);
236 const outcomesByType = groupBy(
237 outcomes.map(outcomeToNullableBool),
238 (it) => it,
239 ) as { null: null[]; true: true[]; false: false[] };
240 expect(outcomeToNullableBool(result)).toBe(
241 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
242 outcomesByType['true']?.length === 1 && !outcomesByType['null']
243 ? true
244 : // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
245 outcomesByType['null']?.length > 0 &&
246 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
247 (outcomesByType['true']?.length ?? 0) < 2
248 ? null
249 : false,
250 );
251 }),
252 );
253 });
254 });
255 });
256
257 describe('tryGetOutcomeFromPartialOutcomes', () => {
258 test("should never return a different logical result than we'd get from all outcomes", () => {
259 fc.assert(
260 fc.property(
261 fc.array(anyOutcomeArbitrary),
262 ConditionConjunctionArbitrary,
263 (outcomes, conjunction) => {
264 const resultFromAllOutcomes = getConditionSetOutcome(
265 outcomes,
266 conjunction,
267 );
268 const randomOutcomeSubset = sampleSize(
269 outcomes,
270 Math.floor(Math.random() * outcomes.length),
271 );
272 const resultFromPartialOutcomes = tryGetOutcomeFromPartialOutcomes(
273 randomOutcomeSubset,
274 conjunction,
275 );
276
277 // We have to cast to a nullable bool because the exact
278 // ConditionCompletionOutcome is undetermined; e.g., if outcomes is
279 // [INAPPLICABLE, FAILED] with conjunction AND, we could get either
280 // when calling `tryGetOutcomeFromPartialOutcomes` w/ a subset.
281 return (
282 resultFromPartialOutcomes === undefined ||
283 outcomeToNullableBool(resultFromAllOutcomes) ===
284 outcomeToNullableBool(resultFromPartialOutcomes)
285 );
286 },
287 ),
288 );
289 });
290 });
291});