Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ removes disposable volumes; native teardown stops the server and preserves state
are no shared authentication files or setup dependency. Cookies follow the configured host.
The transport test checks BigInt/Date serialization through the actual Next.js API endpoint.

`pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` except `src/tests/integration/`.
`pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` and `tests/**/*.{test,spec}.{ts,tsx}`,
except `src/tests/integration/` and `tests/e2e/`.
`pnpm test:integration` selects that integration directory only. Playwright selects `tests/e2e/`.
Failures retain traces, screenshots, videos, and the HTML report even when local retries are
disabled. CI uploads `test-results/e2e` and `playwright-report` on failure and always stops its
Expand Down
7 changes: 5 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export default () =>
configureJest({
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/src/tests/setup/unit.ts'],
testMatch: ['<rootDir>/src/**/*.{test,spec}.{ts,tsx}'],
testPathIgnorePatterns: ['<rootDir>/src/tests/integration/'],
testMatch: [
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
'<rootDir>/tests/**/*.{test,spec}.{ts,tsx}',
],
testPathIgnorePatterns: ['<rootDir>/src/tests/integration/', '<rootDir>/tests/e2e/'],
});
145 changes: 145 additions & 0 deletions src/lib/shareSplit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { BigMath } from '~/utils/numbers';

const MAX_SHARE_OPERATIONS = 20000;

interface WeightBounds {
min: bigint;
max: bigint;
}

const getRoundedWeightBounds = (
cost: bigint,
totalCost: bigint,
weightCount: bigint,
): WeightBounds => ({
min: BigMath.max(1n, ((cost - 1n) * weightCount + totalCost - 1n) / totalCost),
max: ((cost + 1n) * weightCount - 1n) / totalCost,
});

/** Round proportional weights to a fixed total, respecting any required rounding intervals. */
const apportionWeights = (
costs: bigint[],
totalCost: bigint,
weightCount: bigint,
bounds = costs.map(() => ({ min: 0n, max: weightCount })),
): bigint[] | undefined => {
if (
bounds.some(({ min, max }) => min > max) ||
weightCount < bounds.reduce((sum, { min }) => sum + min, 0n) ||
weightCount > bounds.reduce((sum, { max }) => sum + max, 0n)
) {
return undefined;
}

const weights = costs.map((cost, index) =>
BigMath.min(
bounds[index]!.max,
BigMath.max(bounds[index]!.min, (cost * weightCount) / totalCost),
),
);
const remaining = weightCount - weights.reduce((sum, weight) => sum + weight, 0n);
if (0n === remaining) {
return weights;
}
if (0n > remaining) {
return undefined;
}

const candidates = costs
.map((cost, index) => ({ index, deficit: cost * weightCount - weights[index]! * totalCost }))
.filter(({ index }) => weights[index]! < bounds[index]!.max)
.toSorted((a, b) =>
a.deficit === b.deficit ? a.index - b.index : a.deficit > b.deficit ? -1 : 1,
);

// Rounding down leaves fewer than one extra unit per participant to allocate.
if (remaining > BigInt(candidates.length)) {
return undefined;
}
candidates.slice(0, Number(remaining)).forEach(({ index }) => {
weights[index]! += 1n;
});
return weights;
};

/** Prefer whole percentages, then tenths, then hundredths, always summing to 100%. */
export const recoverPercentageWeights = (costs: bigint[]): bigint[] => {
const totalCost = costs.reduce((sum, cost) => sum + cost, 0n);
if (0n === totalCost) {
return costs.map(() => 0n);
}

// Exactly three precision levels: the number of candidates never grows with the amount.
for (const weightCount of [100n, 1000n, 10000n]) {
const bounds = costs.map((cost) =>
0n === cost ? { min: 0n, max: 0n } : getRoundedWeightBounds(cost, totalCost, weightCount),
);
const weights = apportionWeights(costs, totalCost, weightCount, bounds);
if (weights) {
return weights.map((weight) => (weight * 10000n) / weightCount);
}
}

// Some tiny portions cannot be represented at 0.01% precision. Use the nearest
// Balanced percentages rather than returning an invalid total such as 99.99%.
return apportionWeights(costs, totalCost, 10000n)!;
};

/** Find the smallest integer ratio consistent with truncation and leftover-penny allocation. */
export const recoverShareWeights = (costs: bigint[]): bigint[] => {
const positiveCosts = costs.filter((cost) => 0n < cost);
const exactGcd = positiveCosts.reduce((gcd, cost) => BigMath.gcd(gcd, cost), 0n);
const exactWeights = costs.map((cost) => (0n === exactGcd ? 0n : (cost * 100n) / exactGcd));

if (2 > positiveCosts.length) {
return exactWeights;
}

const totalCost = positiveCosts.reduce((sum, cost) => sum + cost, 0n);
const exactShareCount = totalCost / exactGcd;
let operations = 0;

// Search total share counts in ascending order, checking each candidate in linear time.
// Allowed weight intervals avoid enumerating combinations of pennies.
for (let shareCount = BigInt(positiveCosts.length); shareCount < exactShareCount; shareCount++) {
const bounds: { min: bigint; max: bigint }[] = [];
let minSum = 0n;
let maxSum = 0n;

for (const cost of positiveCosts) {
if (MAX_SHARE_OPERATIONS <= operations) {
return exactWeights;
}
operations++;

// A cost equals floor(totalCost * weight / shareCount), with at most one extra penny.
// Thus: cost - 1 <= totalCost * weight / shareCount < cost + 1.
const { min, max } = getRoundedWeightBounds(cost, totalCost, shareCount);
if (min > max || minSum + min > shareCount) {
break;
}

bounds.push({ min, max });
minSum += min;
maxSum += max;
}

if (bounds.length === positiveCosts.length && shareCount <= maxSum) {
let remaining = shareCount - minSum;
let positiveIndex = 0;

return costs.map((cost) => {
if (0n === cost) {
return 0n;
}

const { min, max } = bounds[positiveIndex++]!;
const extra = BigMath.min(remaining, max - min);
remaining -= extra;
return (min + extra) * 100n;
});
}
}

return exactWeights;
};
42 changes: 14 additions & 28 deletions src/store/addStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { create } from 'zustand';

import { DEFAULT_CATEGORY } from '~/lib/category';
import { type CurrencyCode } from '~/lib/currency';
import { recoverPercentageWeights, recoverShareWeights } from '~/lib/shareSplit';
import type { TransactionAddInputModel } from '~/types';
import { shuffleArray } from '~/utils/array';
import { BigMath } from '~/utils/numbers';
Expand Down Expand Up @@ -304,7 +305,7 @@ export function calculateParticipantSplit(
amount: ((getSplitShare(p) ?? 0n) * amount) / 10000n,
}));
canSplitScreenClosed =
0 === 100 - participants.reduce((acc, p) => acc + Number(getSplitShare(p) ?? 0n) / 100, 0);
10000n === participants.reduce((acc, p) => acc + (getSplitShare(p) ?? 0n), 0n);
break;
case SplitType.SHARE:
const totalShare = participants.reduce((acc, p) => acc + Number(getSplitShare(p) ?? 0n), 0);
Expand Down Expand Up @@ -406,34 +407,19 @@ export function calculateSplitShareBasedOnAmount(
break;

case SplitType.PERCENTAGE:
participants.forEach((p) => {
splitShares[p.id]![splitType] =
0n === amount
? 0n
: paidBy?.id !== p.id
? (BigMath.abs(p.amount ?? 0n) * 10000n) / amount
: (BigMath.abs(amount - (p.amount ?? 0n)) * 10000n) / amount;
});

break;

case SplitType.SHARE:
const amounts = participants
.filter(({ amount }) => Boolean(amount))
.map((p) =>
p.id === paidBy?.id ? BigMath.abs(amount - p.amount!) : BigMath.abs(p.amount!),
)
.filter((s) => s !== 0n);

const gcdValue = amounts.length > 1 ? amounts.reduce((a, b) => BigMath.gcd(a, b)) : 1n;

participants.forEach((p) => {
splitShares[p.id]![splitType] =
0n === amount
? 0n
: ((p.id === paidBy?.id ? BigMath.abs(amount - p.amount!) : BigMath.abs(p.amount!)) *
100n) /
gcdValue;
const costs = participants.map((p) =>
p.id === paidBy?.id ? BigMath.abs(amount - (p.amount ?? 0n)) : BigMath.abs(p.amount ?? 0n),
);
const shares =
0n === amount
? costs.map(() => 0n)
: SplitType.PERCENTAGE === splitType
? recoverPercentageWeights(costs)
: recoverShareWeights(costs);

participants.forEach((p, index) => {
splitShares[p.id]![splitType] = shares[index];
});

break;
Expand Down
Loading
Loading