Skip to content

Commit

Permalink
feat(suite): distribution for ab testing
Browse files Browse the repository at this point in the history
  • Loading branch information
adderpositive committed Nov 8, 2024
1 parent fedf726 commit c16f0f3
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ReactElement } from 'react';

import { useDistribution } from 'src/hooks/distribution/useDistribution';

interface DistributionWrapperProps {
idOfDistribution: string;
components: Array<{
variant: string;
element: ReactElement;
}>;
}

/**
* @param components last item in components is default
*/
export const DistributionWrapper = ({
idOfDistribution,
components,
}: DistributionWrapperProps): ReactElement | null => {
const { distribution, activeDistributionVariant } = useDistribution(idOfDistribution);
const areComponentEmpty = !components.length;

if (areComponentEmpty) return null;

const defaultComponent = components[components.length - 1];
const distributionOrVariantNotFound = !distribution || !activeDistributionVariant;
const distributionAndComponentsMismatch = distribution?.groups.length !== components.length;

if (distributionOrVariantNotFound || distributionAndComponentsMismatch)
return defaultComponent.element;

const activeComponent = components.find(
component => component.variant === activeDistributionVariant.variant,
);

return activeComponent?.element ?? defaultComponent.element;
};
22 changes: 22 additions & 0 deletions packages/suite/src/hooks/distribution/useDistribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMemo } from 'react';

import { selectAnalyticsInstanceId } from '@suite-common/analytics';
import { selectDistributionById } from '@suite-common/message-system';

import { useSelector } from 'src/hooks/suite';
import { selectActiveDistributionGroup } from 'src/utils/suite/distribution';

export const useDistribution = (idOfDistribution: string) => {
const state = useSelector(state => state);
const instanceId = selectAnalyticsInstanceId(state);
const distribution = selectDistributionById(state, idOfDistribution);
const activeDistributionVariant = useMemo(
() => selectActiveDistributionGroup({ instanceId, distribution }),
[instanceId, distribution],
);

return {
distribution,
activeDistributionVariant,
};
};
23 changes: 23 additions & 0 deletions packages/suite/src/utils/suite/__fixtures__/distribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getWeakRandomId } from '@trezor/utils';

// getWeakRandomId is also used for generating instanceId
export const getArrayOfInstanceIds = (count: number) =>
Array.from({ length: count }, () => getWeakRandomId(10));

export const distributionTest = {
id: 'distribution-test',
priority: 100,
dismissible: false,
variant: 'info' as const,
category: 'distribution' as const,
groups: [
{
variant: 'A',
percentage: 20,
},
{
variant: 'B',
percentage: 80,
},
],
};
70 changes: 70 additions & 0 deletions packages/suite/src/utils/suite/__tests__/distribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { distributionTest, getArrayOfInstanceIds } from 'src/utils/suite/__fixtures__/distribution';
import {
getDistributionGroupByInclusion,
getInclusionFromInstanceId,
selectActiveDistributionGroup,
} from 'src/utils/suite/distribution';

describe('testing distribution utils', () => {
it('test getInclusionFromInstanceId whether returns percentage between 0 and 99', () => {
const arrayOfIds = getArrayOfInstanceIds(100);
const isExistNumberOutOfRange = arrayOfIds.some(id => {
const percentage = getInclusionFromInstanceId(id);

return percentage < 0 || percentage > 99;
});

expect(isExistNumberOutOfRange).toEqual(false);
});

it('test getDistributionGroupByInclusion whether instanceId is not in range of variants', () => {
const arrayOfIds = getArrayOfInstanceIds(100);
const isExistInstanceIdNotInVariantRange = arrayOfIds.some(id => {
const inclusion = getInclusionFromInstanceId(id);
const group = getDistributionGroupByInclusion({
groups: distributionTest.groups,
inclusion,
});

return group === undefined;
});

expect(isExistInstanceIdNotInVariantRange).toEqual(false);
});

it('test selectActiveDistributionGroup share of variant inclusion', () => {
const deviation = 0.05;
const sampleSize = 1000;
let groupACount = 0;
let groupBCount = 0;

const arrayOfIds = getArrayOfInstanceIds(sampleSize);

arrayOfIds.forEach(id => {
const selectedGroup = selectActiveDistributionGroup({
distribution: distributionTest,
instanceId: id,
});

if (selectedGroup?.variant === 'A') {
groupACount += 1;
}

if (selectedGroup?.variant === 'B') {
groupBCount += 1;
}
});

const shareA = groupACount / sampleSize;
const shareB = groupBCount / sampleSize;

expect(shareA).toBeGreaterThanOrEqual(
distributionTest.groups[0].percentage / 100 - deviation,
);
expect(shareA).toBeLessThanOrEqual(distributionTest.groups[0].percentage / 100 + deviation);
expect(shareB).toBeGreaterThanOrEqual(
distributionTest.groups[1].percentage / 100 - deviation,
);
expect(shareB).toBeLessThanOrEqual(distributionTest.groups[1].percentage / 100 + deviation);
});
});
64 changes: 64 additions & 0 deletions packages/suite/src/utils/suite/distribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createHash } from 'crypto';

import { Groups, Message } from '@suite-common/suite-types';
import { RequiredKey } from '@trezor/type-utils';

interface DistributionMessageProps extends RequiredKey<Message, 'groups'> {}

interface DistributionCategoriesProps {
distribution: DistributionMessageProps | undefined;
instanceId: string | undefined;
}

interface DistributionGetGroupByInclusion {
groups: DistributionMessageProps['groups'];
inclusion: number;
}

/**
* @returns number between 0 and 99 generated from instanceId
*/
export const getInclusionFromInstanceId = (instanceId: string) => {
const hash = createHash('sha256').update(instanceId).digest('hex').slice(0, 8);

return parseInt(hash, 16) % 100;
};

export const getDistributionGroupByInclusion = ({
groups,
inclusion,
}: DistributionGetGroupByInclusion): Groups[number] | undefined => {
let currentPercentage = 0;

const extendedDistribution = groups.map(group => {
const result = {
group,
range: [currentPercentage, currentPercentage + group.percentage - 1],
};

currentPercentage += group.percentage;

return result;
});

return extendedDistribution.find(
group => group.range[0] <= inclusion && group.range[1] >= inclusion,
)?.group;
};

export const selectActiveDistributionGroup = ({
distribution,
instanceId,
}: DistributionCategoriesProps): Groups[number] | undefined => {
if (!instanceId || !distribution) return undefined;

const inclusionFromInstanceId = getInclusionFromInstanceId(instanceId);
const { groups } = distribution;

const distributionRange = getDistributionGroupByInclusion({
groups,
inclusion: inclusionFromInstanceId,
});

return distributionRange;
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { CoinmarketFormContextValues } from 'src/types/coinmarket/coinmarketForm
import { FORM_EXCHANGE_DEX, FORM_EXCHANGE_TYPE } from 'src/constants/wallet/coinmarket/form';
import { useCoinmarketInfo } from 'src/hooks/wallet/coinmarket/useCoinmarketInfo';
import { CoinmarketFormOffersSwitcher } from 'src/views/wallet/coinmarket/common/CoinmarketForm/CoinmarketFormOffersSwitcher';
import { DistributionWrapper } from 'src/components/suite/Distribution/DistributionWrapper';

const getSelectedQuote = (
context: CoinmarketFormContextValues<CoinmarketTradeType>,
Expand Down Expand Up @@ -143,22 +144,53 @@ export const CoinmarketFormOffer = () => {
/>
)}
</Column>
<Button
onClick={() => {
if (quote) {
selectQuote(quote);
}
}}
variant="primary"
margin={{
top: spacings.md,
}}
isFullWidth
isDisabled={state.isLoadingOrInvalid || !quote}
data-testid={`@coinmarket/form/${type}-button`}
>
<Translation id={coinmarketGetSectionActionLabel(type)} />
</Button>
<DistributionWrapper
idOfDistribution="2d5579ec-a7c2-4c50-9311-c404133c8805"
components={[
{
variant: 'A',
element: (
<Button
onClick={() => {
if (quote) {
selectQuote(quote);
}
}}
variant="warning"
margin={{
top: spacings.md,
}}
isFullWidth
isDisabled={state.isLoadingOrInvalid || !quote}
data-testid={`@coinmarket/form/${type}-button`}
>
<Translation id={coinmarketGetSectionActionLabel(type)} />
</Button>
),
},
{
variant: 'B',
element: (
<Button
onClick={() => {
if (quote) {
selectQuote(quote);
}
}}
variant="primary"
margin={{
top: spacings.md,
}}
isFullWidth
isDisabled={state.isLoadingOrInvalid || !quote}
data-testid={`@coinmarket/form/${type}-button`}
>
<Translation id={coinmarketGetSectionActionLabel(type)} />
</Button>
),
},
]}
/>
</Column>
);
};

0 comments on commit c16f0f3

Please sign in to comment.