diff --git a/packages/dynamic-codecs/README.md b/packages/dynamic-codecs/README.md index 8cf7e4414..3bd407075 100644 --- a/packages/dynamic-codecs/README.md +++ b/packages/dynamic-codecs/README.md @@ -70,6 +70,43 @@ if (isDecodedNode(amount, 'structTypeNode')) { } ``` +## Formatting + +Decoded nodes carry the nodes that decoded them, so their values can be formatted for humans using the presentation metadata of those nodes: units, display nodes, scales and ticks. Each formatter takes the decoded node of its kind. + +| Formatter | Decoded node | Example | +| ------------------ | --------------------------- | ------------------------------- | +| `formatInteger` | `DecodedIntegerTypeNode` | `"1.5 USDC"`, `"42 slots"` | +| `formatFloat` | `DecodedFloatTypeNode` | `"1.5 USD"` | +| `formatFixedPoint` | `DecodedFixedPointTypeNode` | `"123.45%"` | +| `formatDateTime` | `DecodedDateTimeTypeNode` | `"2024-01-01T00:00:00.5Z"` | +| `formatDuration` | `DecodedDurationTypeNode` | `"49:00:00"`, `"-00:00:01.5"` | +| `formatString` | `DecodedStringTypeNode` | `"abcd"`, sliced by its display | + +```ts +import { formatInteger, getNodeCodec, isDecodedNode } from '@codama/dynamic-codecs'; + +// amount: u64 with amountNumberDisplayNode({ decimals: integerValueNode('6'), unit: stringValueNode('USDC') }) +const decoded = getNodeCodec([root, program, amountType]).decode(bytes); +if (isDecodedNode(decoded.type, 'integerTypeNode')) { + formatInteger(decoded.type); // "1.5 USDC" +} +``` + +Numbers are formatted exactly, including 128-bit integers and binary fixed points, using the fixed points of `@solana/codecs`. String slices count Unicode code points, so they never split a character such as an emoji. Date-times are exact ISO 8601 UTC strings for any year. + +- **Units:** the unit of a display node wins over the unit of a type, which is the fallback whenever the former is absent or cannot be resolved. Units follow their value after a space, except `%`, `‰` and `°`. +- **Amounts:** when the `decimals` of an `amountNumberDisplayNode` cannot be resolved, `formatInteger` returns `null` rather than a wrongly scaled amount, so you can present the raw value instead. +- **Ticks:** `formatDateTime` and `formatDuration` return `null` when `ticksPerSecond` is not a positive integer. + +`formatInteger`, `formatFloat` and `formatFixedPoint` accept options: + +| Name | Type | Description | +| ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `formatUnit` | `(value: string, unit: string) => string` | Place a unit next to a value, e.g. `` (value, unit) => `${unit} ${value}` `` for `"USD 40.5"`. Defaults to `formatUnit`, also exported. | +| `numberFormat` | `Intl.NumberFormat` | Format numbers for a locale, e.g. `"1,234,567.89"`. Its options decide the digits shown. Amounts and fixed points are given to it exactly, never through a JavaScript float. | +| `resolveInjectedValue` | `(path: NodePath) => unknown` | Resolve injected values of display nodes, e.g. `decimals` provided by an instruction, from their path through the decoded node. `undefined` means unresolved. | + ## Node paths The full path is needed to resolve link nodes, which may point to other programs, and injected values, which are provided by the enclosing instructions. diff --git a/packages/dynamic-codecs/src/format.ts b/packages/dynamic-codecs/src/format.ts new file mode 100644 index 000000000..c98148dd7 --- /dev/null +++ b/packages/dynamic-codecs/src/format.ts @@ -0,0 +1,334 @@ +import { + AmountNumberDisplayNode, + InjectableIntegerValueNode, + InjectableStringValueNode, + InjectedValueNode, + IntegerFormat, + isNode, + Node, + UnitNumberDisplayNode, +} from '@codama/nodes'; +import { getLastNodeFromPath, NodePath } from '@codama/visitors-core'; +import { + BinaryFixedPoint, + binaryFixedPointToString, + DecimalFixedPoint, + decimalFixedPointToString, + formatBinaryFixedPoint, + formatDecimalFixedPoint, + rawBinaryFixedPoint, + rawDecimalFixedPoint, + Signedness, +} from '@solana/codecs'; + +import type { + DecodedDateTimeTypeNode, + DecodedDurationTypeNode, + DecodedFixedPointTypeNode, + DecodedFloatTypeNode, + DecodedIntegerTypeNode, + DecodedStringTypeNode, +} from './decoded'; + +/** Options shared by the formatters of decoded nodes. */ +export type FormatOptions = { + /** + * Place a unit next to a formatted value, e.g. to write `"USD 40.5"`. Defaults to + * {@link formatUnit}, which appends the unit after a space, or without one for `%`, `‰` and `°`. + */ + formatUnit?: (value: string, unit: string) => string; + /** + * Format numbers for a locale, e.g. `new Intl.NumberFormat('en-US')`. Its options decide the + * digits shown, e.g. 3 fraction digits at most by default. Amounts and fixed points are given + * to it exactly, never through a JavaScript float. Defaults to plain digits with every + * fraction digit, e.g. `"1234.56789"`. + */ + numberFormat?: Intl.NumberFormat; + /** + * Resolve the value of an injected value node used by a display node, e.g. the `decimals` of + * an amount provided by its instruction, given its path through the decoded node, e.g. + * `[...decoded.path, amountNumberDisplayNode, injectedValueNode]`. The resolver handles + * providers and fallbacks. Returning `undefined` leaves the value unresolved. + */ + resolveInjectedValue?: (path: NodePath) => unknown; +}; + +/** The units written straight after their value, without a space. */ +const UNITS_WITHOUT_SPACE: readonly string[] = ['%', '‰', '°']; + +/** + * Place a unit after a formatted value, separated by a space, except for units written + * straight after their value: `%`, `‰` and `°`. + * + * @example + * ```ts + * formatUnit('1.5', 'SOL'); // "1.5 SOL" + * formatUnit('12.5', '%'); // "12.5%" + * ``` + */ +export function formatUnit(value: string, unit: string): string { + return UNITS_WITHOUT_SPACE.includes(unit) ? `${value}${unit}` : `${value} ${unit}`; +} + +/** + * Format a decoded integer using its display node, if any, and its unit. + * + * - With an `amountNumberDisplayNode`, the integer is divided by `10 ^ decimals`, e.g. `1500000` + * with 6 decimals gives `"1.5"`. When `decimals` cannot be resolved, `null` is returned rather + * than a wrongly scaled amount, so callers can present the raw value instead. + * - The unit of its display node wins, and the unit of its type is the fallback whenever the + * former is absent or cannot be resolved. + * + * @example + * ```ts + * // u64 with amountNumberDisplayNode({ decimals: integerValueNode('6'), unit: stringValueNode('USDC') }) + * formatInteger(decoded); // "1.5 USDC" + * ``` + */ +export function formatInteger(decoded: DecodedIntegerTypeNode, options: FormatOptions = {}): string | null { + const node = getLastNodeFromPath(decoded.path); + const display = node.display; + let text: string; + if (isNode(display, 'amountNumberDisplayNode')) { + const decimals = resolveIntegerInput(decoded.path, display, display.decimals, options); + if (decimals === undefined) return null; + const { signedness, totalBits } = getIntegerLayout(node.format); + text = formatDecimal(rawDecimalFixedPoint(signedness, totalBits, decimals)(decoded.value), options); + } else { + text = options.numberFormat ? options.numberFormat.format(decoded.value) : decoded.value.toString(); + } + return withUnit(text, getDisplayUnit(decoded.path, display, options) ?? node.unit, options); +} + +/** + * Format a decoded float with its unit, the unit of its display node winning over the unit of + * its type. + * + * @example + * ```ts + * // f64 with unit 'USD' + * formatFloat(decoded); // "1.5 USD" + * ``` + */ +export function formatFloat(decoded: DecodedFloatTypeNode, options: FormatOptions = {}): string { + const node = getLastNodeFromPath(decoded.path); + const text = options.numberFormat ? options.numberFormat.format(decoded.value) : decoded.value.toString(); + return withUnit(text, getDisplayUnit(decoded.path, node.display, options) ?? node.unit, options); +} + +/** + * Format a decoded fixed point as its exact value, `raw / base ^ scale`, with its unit, the + * unit of its display node winning over the unit of its type. Binary fixed points (base 2) + * have finite decimal expansions, so they are exact too. + * + * @example + * ```ts + * // fixedPointTypeNode(u32, 2, { unit: '%' }) + * formatFixedPoint(decoded); // "123.45%" + * ``` + */ +export function formatFixedPoint(decoded: DecodedFixedPointTypeNode, options: FormatOptions = {}): string { + const node = getLastNodeFromPath(decoded.path); + const base = node.base ?? 10; + const { signedness, totalBits } = getIntegerLayout(node.number.format); + const text = + base === 2 + ? formatBinary(rawBinaryFixedPoint(signedness, totalBits, node.scale)(decoded.value), options) + : formatDecimal(rawDecimalFixedPoint(signedness, totalBits, node.scale)(decoded.value), options); + return withUnit(text, getDisplayUnit(decoded.path, node.display, options) ?? node.unit, options); +} + +/** + * Format a decoded date-time, a number of ticks since the Unix epoch, as an ISO 8601 UTC + * date-time, exact for any year. Years beyond `0000`–`9999` use the expanded form of + * `Date.prototype.toISOString`, e.g. `+275761` or `-000001`. Fractions of a second are shown + * when not zero, exactly when `ticksPerSecond` is a power of 10 and rounded to the + * nanosecond otherwise. + * + * Returns `null` when `ticksPerSecond` is not a positive integer. + * + * @example + * ```ts + * formatDateTime(decoded); // "2024-01-01T00:00:00Z" + * ``` + */ +export function formatDateTime(decoded: DecodedDateTimeTypeNode): string | null { + const time = toSeconds(decoded.value, getLastNodeFromPath(decoded.path).ticksPerSecond); + if (!time) return null; + const days = floorDiv(time.seconds, SECONDS_PER_DAY); + const secondsOfDay = time.seconds - days * SECONDS_PER_DAY; + const [year, month, day] = getCivilDate(days); + const date = `${formatYear(year)}-${pad(month)}-${pad(day)}`; + return `${date}T${formatClock(secondsOfDay)}${time.fraction}Z`; +} + +/** + * Format a decoded duration, a number of ticks, as `HH:mm:ss`, hours going beyond 24 when + * needed, e.g. `"49:00:00"`, with a leading `-` when negative. Fractions of a second are shown + * as for {@link formatDateTime}. + * + * Returns `null` when `ticksPerSecond` is not a positive integer. + * + * @example + * ```ts + * formatDuration(decoded); // "01:30:00" + * ``` + */ +export function formatDuration(decoded: DecodedDurationTypeNode): string | null { + const negative = decoded.value < 0n; + const time = toSeconds(negative ? -decoded.value : decoded.value, getLastNodeFromPath(decoded.path).ticksPerSecond); + if (!time) return null; + return `${negative ? '-' : ''}${formatClock(time.seconds)}${time.fraction}`; +} + +/** + * Format a decoded string, sliced to the `[sliceStart, sliceEnd)` range of its display node, + * if any. Indices count Unicode code points, so a slice never splits a character such as an + * emoji. + * + * @example + * ```ts + * // stringTypeNode('utf8', { display: stringDisplayNode({ sliceEnd: 4 }) }) + * formatString(decoded); // "abcd" + * ``` + */ +export function formatString(decoded: DecodedStringTypeNode): string { + const display = getLastNodeFromPath(decoded.path).display; + if (!display) return decoded.value; + return Array.from(decoded.value) + .slice(display.sliceStart ?? 0, display.sliceEnd) + .join(''); +} + +const SECONDS_PER_DAY = 86_400n; + +/** The signedness and bit width of an integer format, for Kit's fixed points. */ +function getIntegerLayout(format: IntegerFormat): { signedness: Signedness; totalBits: number } { + // `shortU16` is a variable-size encoding of an unsigned 16-bit integer. + if (format === 'shortU16') return { signedness: 'unsigned', totalBits: 16 }; + return { signedness: format.startsWith('i') ? 'signed' : 'unsigned', totalBits: Number(format.slice(1)) }; +} + +function formatDecimal(value: DecimalFixedPoint, options: FormatOptions): string { + return options.numberFormat + ? formatDecimalFixedPoint(options.numberFormat, value) + : decimalFixedPointToString(value); +} + +function formatBinary(value: BinaryFixedPoint, options: FormatOptions): string { + return options.numberFormat ? formatBinaryFixedPoint(options.numberFormat, value) : binaryFixedPointToString(value); +} + +function withUnit(text: string, unit: string | undefined, options: FormatOptions): string { + if (!unit) return text; + return (options.formatUnit ?? formatUnit)(text, unit); +} + +/** The unit of a number's display node, if it has one that resolves. */ +function getDisplayUnit( + path: NodePath, + display: AmountNumberDisplayNode | UnitNumberDisplayNode | undefined, + options: FormatOptions, +): string | undefined { + if (!display?.unit) return undefined; + return resolveStringInput(path, display, display.unit, options); +} + +/** A non-negative integer input of a display node, e.g. the `decimals` of an amount, if it resolves. */ +function resolveIntegerInput( + path: NodePath, + display: Node, + input: InjectableIntegerValueNode, + options: FormatOptions, +): number | undefined { + const value: unknown = isNode(input, 'integerValueNode') + ? BigInt(input.value) + : options.resolveInjectedValue?.([...path, display, input]); + if (typeof value === 'bigint' && value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(value); + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value; + return undefined; +} + +/** A non-empty string input of a display node, e.g. the `unit` of an amount, if it resolves. */ +function resolveStringInput( + path: NodePath, + display: Node, + input: InjectableStringValueNode, + options: FormatOptions, +): string | undefined { + const value: unknown = isNode(input, 'stringValueNode') + ? input.string + : options.resolveInjectedValue?.([...path, display, input]); + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** + * Split a non-negative number of ticks into whole seconds and the fraction of a second, + * e.g. `".5"`. Ticks that are not powers of 10 of a second are rounded to the nanosecond. + */ +function toSeconds( + ticks: bigint, + ticksPerSecond: number | undefined = 1, +): { fraction: string; seconds: bigint } | undefined { + if (!Number.isSafeInteger(ticksPerSecond) || ticksPerSecond <= 0) return undefined; + let perSecond = BigInt(ticksPerSecond); + let digits = getPowerOfTen(perSecond); + if (digits === undefined) { + // Round to the nearest nanosecond, halves away from zero. + const nanoseconds = ticks * 1_000_000_000n; + const half = nanoseconds < 0n ? -perSecond / 2n : perSecond / 2n; + ticks = (nanoseconds + half) / perSecond; + perSecond = 1_000_000_000n; + digits = 9; + } + const seconds = floorDiv(ticks, perSecond); + const remainder = ticks - seconds * perSecond; + const fraction = remainder === 0n ? '' : `.${remainder.toString().padStart(digits, '0').replace(/0+$/, '')}`; + return { fraction, seconds }; +} + +/** The number of zeros of a power of 10, e.g. 3 for 1000, if it is one. */ +function getPowerOfTen(value: bigint): number | undefined { + const digits = value.toString(); + return /^10*$/.test(digits) ? digits.length - 1 : undefined; +} + +function floorDiv(dividend: bigint, divisor: bigint): bigint { + const quotient = dividend / divisor; + return dividend % divisor !== 0n && dividend < 0n !== divisor < 0n ? quotient - 1n : quotient; +} + +/** + * The proleptic Gregorian year, month and day of a number of days since the Unix epoch, + * using Howard Hinnant's `civil_from_days` algorithm, exact for any number of days. + */ +function getCivilDate(daysSinceEpoch: bigint): [year: bigint, month: bigint, day: bigint] { + const days = daysSinceEpoch + 719_468n; + const era = floorDiv(days, 146_097n); + const dayOfEra = days - era * 146_097n; + const yearOfEra = (dayOfEra - dayOfEra / 1_460n + dayOfEra / 36_524n - dayOfEra / 146_096n) / 365n; + const dayOfYear = dayOfEra - (365n * yearOfEra + yearOfEra / 4n - yearOfEra / 100n); + const shiftedMonth = (5n * dayOfYear + 2n) / 153n; + const day = dayOfYear - (153n * shiftedMonth + 2n) / 5n + 1n; + const month = shiftedMonth < 10n ? shiftedMonth + 3n : shiftedMonth - 9n; + const year = yearOfEra + era * 400n + (month <= 2n ? 1n : 0n); + return [year, month, day]; +} + +/** An ISO 8601 year: 4 digits within `0000`–`9999`, and a sign with at least 6 digits otherwise. */ +function formatYear(year: bigint): string { + if (year >= 0n && year <= 9999n) return year.toString().padStart(4, '0'); + return `${year < 0n ? '-' : '+'}${(year < 0n ? -year : year).toString().padStart(6, '0')}`; +} + +/** `HH:mm:ss` for a non-negative number of seconds, hours going beyond 24 when needed. */ +function formatClock(totalSeconds: bigint): string { + const hours = totalSeconds / 3_600n; + const minutes = (totalSeconds % 3_600n) / 60n; + const seconds = totalSeconds % 60n; + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; +} + +function pad(value: bigint): string { + return value.toString().padStart(2, '0'); +} diff --git a/packages/dynamic-codecs/src/index.ts b/packages/dynamic-codecs/src/index.ts index 6963842e9..c783297eb 100644 --- a/packages/dynamic-codecs/src/index.ts +++ b/packages/dynamic-codecs/src/index.ts @@ -6,6 +6,7 @@ import { getValueNodeVisitor } from './values'; export * from './codecs'; export * from './decoded'; +export * from './format'; export * from './values'; export type { ReadonlyUint8Array }; diff --git a/packages/dynamic-codecs/test/format/formatDateTime.test.ts b/packages/dynamic-codecs/test/format/formatDateTime.test.ts new file mode 100644 index 000000000..512bfb98f --- /dev/null +++ b/packages/dynamic-codecs/test/format/formatDateTime.test.ts @@ -0,0 +1,70 @@ +import { dateTimeTypeNode, integerTypeNode } from '@codama/nodes'; +import { getI64Encoder } from '@solana/codecs'; +import { expect, test } from 'vitest'; + +import { formatDateTime, getNodeCodec } from '../../src'; + +/** Decode a date-time from its raw number of ticks. */ +function decodeDateTime(ticks: bigint, ticksPerSecond?: number) { + const node = dateTimeTypeNode(integerTypeNode('i64'), ticksPerSecond === undefined ? {} : { ticksPerSecond }); + return getNodeCodec([node]).decode(getI64Encoder().encode(ticks)); +} + +test('it formats the Unix epoch', () => { + expect(formatDateTime(decodeDateTime(0n))).toBe('1970-01-01T00:00:00Z'); +}); + +test('it formats seconds since the Unix epoch', () => { + expect(formatDateTime(decodeDateTime(1_704_067_200n))).toBe('2024-01-01T00:00:00Z'); +}); + +test('it formats leap days', () => { + expect(formatDateTime(decodeDateTime(1_709_208_000n))).toBe('2024-02-29T12:00:00Z'); +}); + +test('it formats dates before the Unix epoch', () => { + expect(formatDateTime(decodeDateTime(-1n))).toBe('1969-12-31T23:59:59Z'); +}); + +test('it formats milliseconds as fractions of a second', () => { + expect(formatDateTime(decodeDateTime(1_704_067_200_500n, 1000))).toBe('2024-01-01T00:00:00.5Z'); +}); + +test('it formats nanoseconds exactly', () => { + expect(formatDateTime(decodeDateTime(1_704_067_200_123_456_789n, 1_000_000_000))).toBe( + '2024-01-01T00:00:00.123456789Z', + ); +}); + +test('it formats fractions before the Unix epoch', () => { + expect(formatDateTime(decodeDateTime(-500n, 1000))).toBe('1969-12-31T23:59:59.5Z'); +}); + +test('it rounds ticks that are not powers of 10 of a second to the nanosecond', () => { + // 1 tick of 1/3 second. + expect(formatDateTime(decodeDateTime(1n, 3))).toBe('1970-01-01T00:00:00.333333333Z'); +}); + +test('it formats years beyond 9999 with an expanded year', () => { + expect(formatDateTime(decodeDateTime(253_402_300_800n))).toBe('+010000-01-01T00:00:00Z'); +}); + +test('it formats years before 0000 with an expanded year', () => { + // One second before 0000-01-01T00:00:00Z. + expect(formatDateTime(decodeDateTime(-62_167_219_201n))).toBe('-000001-12-31T23:59:59Z'); +}); + +test('it formats dates beyond the range of JavaScript dates exactly', () => { + // The largest i64 number of seconds. + expect(formatDateTime(decodeDateTime(9_223_372_036_854_775_807n))).toBe('+292277026596-12-04T15:30:07Z'); +}); + +test('it matches JavaScript dates within their range', () => { + const seconds = [-8_640_000_000_000n, -62_135_596_800n, 0n, 951_782_400n, 8_640_000_000_000n]; + const expected = seconds.map(second => new Date(Number(second) * 1000).toISOString().replace('.000Z', 'Z')); + expect(seconds.map(second => formatDateTime(decodeDateTime(second)))).toStrictEqual(expected); +}); + +test('it does not format date-times whose ticks per second are not positive integers', () => { + expect(formatDateTime(decodeDateTime(1n, 0))).toBeNull(); +}); diff --git a/packages/dynamic-codecs/test/format/formatDuration.test.ts b/packages/dynamic-codecs/test/format/formatDuration.test.ts new file mode 100644 index 000000000..a02cba28c --- /dev/null +++ b/packages/dynamic-codecs/test/format/formatDuration.test.ts @@ -0,0 +1,40 @@ +import { durationTypeNode, integerTypeNode } from '@codama/nodes'; +import { getI64Encoder } from '@solana/codecs'; +import { expect, test } from 'vitest'; + +import { formatDuration, getNodeCodec } from '../../src'; + +/** Decode a duration from its raw number of ticks. */ +function decodeDuration(ticks: bigint, ticksPerSecond?: number) { + const node = durationTypeNode(integerTypeNode('i64'), ticksPerSecond === undefined ? {} : { ticksPerSecond }); + return getNodeCodec([node]).decode(getI64Encoder().encode(ticks)); +} + +test('it formats durations as hours, minutes and seconds', () => { + expect(formatDuration(decodeDuration(5_400n))).toBe('01:30:00'); +}); + +test('it formats durations of more than a day in hours', () => { + expect(formatDuration(decodeDuration(176_400n))).toBe('49:00:00'); +}); + +test('it formats negative durations', () => { + expect(formatDuration(decodeDuration(-1n))).toBe('-00:00:01'); +}); + +test('it formats milliseconds as fractions of a second', () => { + expect(formatDuration(decodeDuration(90_500n, 1000))).toBe('00:01:30.5'); +}); + +test('it formats negative fractions of a second', () => { + expect(formatDuration(decodeDuration(-1_500n, 1000))).toBe('-00:00:01.5'); +}); + +test('it rounds ticks that are not powers of 10 of a second to the nanosecond', () => { + // 5 ticks of 1/3 second. + expect(formatDuration(decodeDuration(5n, 3))).toBe('00:00:01.666666667'); +}); + +test('it does not format durations whose ticks per second are not positive integers', () => { + expect(formatDuration(decodeDuration(1n, -1))).toBeNull(); +}); diff --git a/packages/dynamic-codecs/test/format/formatFixedPoint.test.ts b/packages/dynamic-codecs/test/format/formatFixedPoint.test.ts new file mode 100644 index 000000000..1b441102a --- /dev/null +++ b/packages/dynamic-codecs/test/format/formatFixedPoint.test.ts @@ -0,0 +1,82 @@ +import { + fixedPointTypeNode, + injectedValueNode, + integerTypeNode, + stringValueNode, + unitNumberDisplayNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { formatFixedPoint, getNodeCodec } from '../../src'; +import { hex } from '../_setup'; + +test('it formats decimal fixed points as their exact value', () => { + // 12345 with a scale of 2. + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2)]).decode(hex('39300000')); + expect(formatFixedPoint(decoded)).toBe('123.45'); +}); + +test('it formats decimal fixed points without trailing zeros', () => { + // 12300 with a scale of 2. + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2)]).decode(hex('0c300000')); + expect(formatFixedPoint(decoded)).toBe('123'); +}); + +test('it formats signed fixed points', () => { + // -12345 with a scale of 2. + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('i32'), 2)]).decode(hex('c7cfffff')); + expect(formatFixedPoint(decoded)).toBe('-123.45'); +}); + +test('it formats binary fixed points as their exact value', () => { + // 16384 with 15 fractional bits, i.e. 0.5 in Q1.15. + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('i16'), 15, { base: 2 })]).decode(hex('0040')); + expect(formatFixedPoint(decoded)).toBe('0.5'); +}); + +test('it formats fixed points with the unit of their type', () => { + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2, { unit: '%' })]).decode( + hex('39300000'), + ); + expect(formatFixedPoint(decoded)).toBe('123.45%'); +}); + +test('it formats fixed points with the unit of their display over the one of their type', () => { + const display = unitNumberDisplayNode({ unit: stringValueNode('SOL') }); + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2, { display, unit: 'tokens' })]).decode( + hex('39300000'), + ); + expect(formatFixedPoint(decoded)).toBe('123.45 SOL'); +}); + +test('it formats 128-bit binary fixed points exactly', () => { + // 2^128 - 1 with 64 fractional bits. + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u128'), 64, { base: 2 })]).decode( + hex('ff'.repeat(16)), + ); + expect(formatFixedPoint(decoded)).toBe( + '18446744073709551615.9999999999999999999457898913757247782996273599565029144287109375', + ); +}); + +test('it formats fixed points with the resolved unit of their display', () => { + const display = unitNumberDisplayNode({ unit: injectedValueNode({ key: 'symbol' }) }); + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2, { display, unit: 'tokens' })]).decode( + hex('39300000'), + ); + expect(formatFixedPoint(decoded, { resolveInjectedValue: () => 'SOL' })).toBe('123.45 SOL'); +}); + +test('it formats fixed points with the unit of their type when their display unit cannot be resolved', () => { + const display = unitNumberDisplayNode({ unit: injectedValueNode({ key: 'symbol' }) }); + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2, { display, unit: 'tokens' })]).decode( + hex('39300000'), + ); + expect(formatFixedPoint(decoded, { resolveInjectedValue: () => undefined })).toBe('123.45 tokens'); +}); + +test('it formats fixed points for a locale', () => { + // 123456789 with a scale of 2. + const decoded = getNodeCodec([fixedPointTypeNode(integerTypeNode('u32'), 2)]).decode(hex('15cd5b07')); + expect(formatFixedPoint(decoded, { numberFormat: new Intl.NumberFormat('en-US') })).toBe('1,234,567.89'); +}); diff --git a/packages/dynamic-codecs/test/format/formatFloat.test.ts b/packages/dynamic-codecs/test/format/formatFloat.test.ts new file mode 100644 index 000000000..137d8e0b3 --- /dev/null +++ b/packages/dynamic-codecs/test/format/formatFloat.test.ts @@ -0,0 +1,22 @@ +import { floatTypeNode, injectedValueNode, unitNumberDisplayNode } from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { formatFloat, getNodeCodec } from '../../src'; +import { hex } from '../_setup'; + +test('it formats floats as their value', () => { + const decoded = getNodeCodec([floatTypeNode('f32')]).decode(hex('0000c03f')); + expect(formatFloat(decoded)).toBe('1.5'); +}); + +test('it formats floats with the unit of their display over the one of their type', () => { + const display = unitNumberDisplayNode({ unit: injectedValueNode({ key: 'currency' }) }); + const decoded = getNodeCodec([floatTypeNode('f32', { display, unit: 'USD' })]).decode(hex('0000c03f')); + expect(formatFloat(decoded, { resolveInjectedValue: () => 'EUR' })).toBe('1.5 EUR'); +}); + +test('it formats floats with the unit of their type when their display unit cannot be resolved', () => { + const display = unitNumberDisplayNode({ unit: injectedValueNode({ key: 'currency' }) }); + const decoded = getNodeCodec([floatTypeNode('f32', { display, unit: 'USD' })]).decode(hex('0000c03f')); + expect(formatFloat(decoded)).toBe('1.5 USD'); +}); diff --git a/packages/dynamic-codecs/test/format/formatInteger.test.ts b/packages/dynamic-codecs/test/format/formatInteger.test.ts new file mode 100644 index 000000000..663ba31ae --- /dev/null +++ b/packages/dynamic-codecs/test/format/formatInteger.test.ts @@ -0,0 +1,160 @@ +import { + amountNumberDisplayNode, + injectedValueNode, + integerTypeNode, + integerValueNode, + stringValueNode, + unitNumberDisplayNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { formatInteger, getNodeCodec } from '../../src'; +import { hex } from '../_setup'; + +test('it formats integers as their digits', () => { + const decoded = getNodeCodec([integerTypeNode('u64')]).decode(hex('2a00000000000000')); + expect(formatInteger(decoded)).toBe('42'); +}); + +test('it formats integers with the unit of their type', () => { + const decoded = getNodeCodec([integerTypeNode('u64', { unit: 'slots' })]).decode(hex('2a00000000000000')); + expect(formatInteger(decoded)).toBe('42 slots'); +}); + +test('it writes some units straight after the value', () => { + const decoded = getNodeCodec([integerTypeNode('u8', { unit: '%' })]).decode(hex('2a')); + expect(formatInteger(decoded)).toBe('42%'); +}); + +test('it formats integers with the unit of their display over the one of their type', () => { + const display = unitNumberDisplayNode({ unit: stringValueNode('lamports') }); + const decoded = getNodeCodec([integerTypeNode('u64', { display, unit: 'units' })]).decode(hex('2a00000000000000')); + expect(formatInteger(decoded)).toBe('42 lamports'); +}); + +test('it formats amounts scaled by their decimals, with their unit', () => { + const display = amountNumberDisplayNode({ decimals: integerValueNode('6'), unit: stringValueNode('USDC') }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded)).toBe('1.5 USDC'); +}); + +test('it formats whole amounts without a fraction', () => { + const display = amountNumberDisplayNode({ decimals: integerValueNode('6') }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('40420f0000000000')); + expect(formatInteger(decoded)).toBe('1'); +}); + +test('it formats negative amounts', () => { + const display = amountNumberDisplayNode({ decimals: integerValueNode('6') }); + const decoded = getNodeCodec([integerTypeNode('i64', { display })]).decode(hex('a01ce9ffffffffff')); + expect(formatInteger(decoded)).toBe('-1.5'); +}); + +test('it formats amounts of 128-bit integers exactly', () => { + // 2^128 - 1 with 18 decimals. + const display = amountNumberDisplayNode({ decimals: integerValueNode('18') }); + const decoded = getNodeCodec([integerTypeNode('u128', { display })]).decode(hex('ff'.repeat(16))); + expect(formatInteger(decoded)).toBe('340282366920938463463.374607431768211455'); +}); + +test('it formats amounts with injected decimals and units', () => { + // Given an amount whose decimals and unit are injected. + const decimals = injectedValueNode({ key: 'decimals' }); + const unit = injectedValueNode({ key: 'symbol' }); + const display = amountNumberDisplayNode({ decimals, unit }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + + // When we format it with a resolver of both, then they are used. + const values = new Map([ + [decimals, 6n], + [unit, 'USDC'], + ]); + expect(formatInteger(decoded, { resolveInjectedValue: path => values.get(path[path.length - 1]) })).toBe( + '1.5 USDC', + ); +}); + +test('it resolves injected values from their path through the decoded node', () => { + const decimals = injectedValueNode({ key: 'decimals' }); + const display = amountNumberDisplayNode({ decimals }); + const node = integerTypeNode('u64', { display }); + const decoded = getNodeCodec([node]).decode(hex('60e3160000000000')); + const paths: unknown[] = []; + formatInteger(decoded, { + resolveInjectedValue: path => { + paths.push(path); + return undefined; + }, + }); + expect(paths).toStrictEqual([[node, display, decimals]]); +}); + +test('it does not format amounts whose decimals cannot be resolved', () => { + const display = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded, { resolveInjectedValue: () => undefined })).toBeNull(); +}); + +test('it does not format amounts whose decimals are injected without a resolver', () => { + const display = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded)).toBeNull(); +}); + +test.each<[string, unknown]>([ + ['negative integers', -1n], + ['fractions', 1.5], + ['strings', '6'], +])('it does not format amounts whose decimals resolve to %s', (_, value) => { + const display = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded, { resolveInjectedValue: () => value })).toBeNull(); +}); + +test('it accepts decimals resolving to numbers', () => { + const display = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded, { resolveInjectedValue: () => 6 })).toBe('1.5'); +}); + +test('it formats amounts with the unit of their type when their own unit cannot be resolved', () => { + const display = amountNumberDisplayNode({ + decimals: integerValueNode('6'), + unit: injectedValueNode({ key: 'symbol' }), + }); + const decoded = getNodeCodec([integerTypeNode('u64', { display, unit: 'tokens' })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded, { resolveInjectedValue: () => undefined })).toBe('1.5 tokens'); +}); + +test('it formats integers for a locale', () => { + const decoded = getNodeCodec([integerTypeNode('u64')]).decode(hex('87d6120000000000')); + expect(formatInteger(decoded, { numberFormat: new Intl.NumberFormat('en-US') })).toBe('1,234,567'); +}); + +test('it formats amounts of shortU16 integers', () => { + // 1500 with 3 decimals. + const display = amountNumberDisplayNode({ decimals: integerValueNode('3') }); + const decoded = getNodeCodec([integerTypeNode('shortU16', { display })]).decode(hex('dc0b')); + expect(formatInteger(decoded)).toBe('1.5'); +}); + +test('it formats amounts with the fraction digits of a locale format', () => { + // 1234567 with 6 decimals. + const display = amountNumberDisplayNode({ decimals: integerValueNode('6') }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('87d6120000000000')); + const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); + expect(formatInteger(decoded, { numberFormat })).toBe('1.23'); +}); + +test('it formats amounts for a locale', () => { + // 1234567890 with 3 decimals. + const display = amountNumberDisplayNode({ decimals: integerValueNode('3') }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('d202964900000000')); + expect(formatInteger(decoded, { numberFormat: new Intl.NumberFormat('en-US') })).toBe('1,234,567.89'); +}); + +test('it places units with a custom function', () => { + const display = amountNumberDisplayNode({ decimals: integerValueNode('6'), unit: stringValueNode('USDC') }); + const decoded = getNodeCodec([integerTypeNode('u64', { display })]).decode(hex('60e3160000000000')); + expect(formatInteger(decoded, { formatUnit: (value, unit) => `${unit} ${value}` })).toBe('USDC 1.5'); +}); diff --git a/packages/dynamic-codecs/test/format/formatString.test.ts b/packages/dynamic-codecs/test/format/formatString.test.ts new file mode 100644 index 000000000..e1a8311f3 --- /dev/null +++ b/packages/dynamic-codecs/test/format/formatString.test.ts @@ -0,0 +1,28 @@ +import { stringDisplayNode, stringTypeNode } from '@codama/nodes'; +import { getUtf8Encoder } from '@solana/codecs'; +import { expect, test } from 'vitest'; + +import { formatString, getNodeCodec } from '../../src'; + +test('it formats strings as their value', () => { + const decoded = getNodeCodec([stringTypeNode('utf8')]).decode(getUtf8Encoder().encode('abcdefg')); + expect(formatString(decoded)).toBe('abcdefg'); +}); + +test('it formats strings sliced by their display', () => { + const display = stringDisplayNode({ sliceEnd: 4, sliceStart: 1 }); + const decoded = getNodeCodec([stringTypeNode('utf8', { display })]).decode(getUtf8Encoder().encode('abcdefg')); + expect(formatString(decoded)).toBe('bcd'); +}); + +test('it slices strings by code points', () => { + const display = stringDisplayNode({ sliceEnd: 1 }); + const decoded = getNodeCodec([stringTypeNode('utf8', { display })]).decode(getUtf8Encoder().encode('👋hi')); + expect(formatString(decoded)).toBe('👋'); +}); + +test('it starts string slices after characters made of several code units', () => { + const display = stringDisplayNode({ sliceStart: 1 }); + const decoded = getNodeCodec([stringTypeNode('utf8', { display })]).decode(getUtf8Encoder().encode('👋hi')); + expect(formatString(decoded)).toBe('hi'); +});