Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 291 lines 11 kB view raw
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});