@rozenite/storage-plugin 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,11 @@
1
1
  import type { MMKV as MMKVV3 } from 'react-native-mmkv-v3';
2
2
  import type { MMKV as MMKVV4 } from 'react-native-mmkv-v4';
3
- import type { StorageAdapter, StorageEntry, StorageNode } from '../../shared/types';
3
+ import type {
4
+ StorageAdapter,
5
+ StorageEntry,
6
+ StorageNode,
7
+ } from '../../shared/types';
4
8
  import { DEFAULT_SUPPORTED_TYPES } from '../../shared/types';
5
- import { looksLikeGarbled } from '../is-garbled';
6
9
 
7
10
  type MMKV = MMKVV3 | MMKVV4;
8
11
 
@@ -36,12 +39,12 @@ const normalizeStorages = (storages: MMKV[] | Record<string, MMKV>) => {
36
39
 
37
40
  if (isAnyStorageV4) {
38
41
  throw new Error(
39
- '[Rozenite] Storage Plugin: MMKV arrays are not supported for v4 storages. Pass a record of storage IDs and MMKV instances.'
42
+ '[Rozenite] Storage Plugin: MMKV arrays are not supported for v4 storages. Pass a record of storage IDs and MMKV instances.',
40
43
  );
41
44
  }
42
45
 
43
46
  return Object.fromEntries(
44
- (storages as MMKVV3[]).map((storage) => [storage['id'], storage])
47
+ (storages as MMKVV3[]).map((storage) => [storage['id'], storage]),
45
48
  );
46
49
  }
47
50
 
@@ -71,54 +74,60 @@ const getMMKVAdapter = (mmkv: MMKV): MMKVAdapter => {
71
74
  getBuffer: (key) => mmkv.getBuffer(key) as ArrayBuffer | undefined,
72
75
  delete: (key) => mmkv.delete(key),
73
76
  getAllKeys: () => mmkv.getAllKeys(),
74
- addOnValueChangedListener: (callback) => mmkv.addOnValueChangedListener(callback),
77
+ addOnValueChangedListener: (callback) =>
78
+ mmkv.addOnValueChangedListener(callback),
75
79
  };
76
80
  };
77
81
 
78
- const getEntry = (adapter: MMKVAdapter, key: string): StorageEntry | undefined => {
82
+ // MMKV's typed getters can disagree on the same key on some platforms
83
+ // `getString` lenient-decodes invalid-UTF-8 buffer bytes to `""`, and
84
+ // `getNumber` reinterprets 8-byte buffers as IEEE 754 doubles without
85
+ // honoring the original `setBuffer` write. `getBuffer` is more reliably
86
+ // strict (only returns for keys actually written via `setBuffer`), so
87
+ // we consult it ahead of the numeric getters to break ties in favour of
88
+ // the bytes that are actually on disk.
89
+ //
90
+ // 1. Non-empty string — the common case. Buffer bytes that happen to
91
+ // be valid UTF-8 surface here too (documented trade-off); users
92
+ // disambiguate at edit time via the Hex editor.
93
+ // 2. Non-empty buffer — catches `setBuffer` payloads regardless of
94
+ // byte count and regardless of whether the other typed getters
95
+ // also return spurious values.
96
+ // 3. Number, then boolean — for keys actually written via `setNumber`
97
+ // / `setBoolean`. `getBuffer` will have returned `undefined` for
98
+ // those keys, so the chain falls through.
99
+ // 4. Empty string — intentional `setString(key, "")` lands here once
100
+ // we've ruled out a non-empty buffer payload at the same key.
101
+ const getEntry = (
102
+ adapter: MMKVAdapter,
103
+ key: string,
104
+ ): StorageEntry | undefined => {
79
105
  const stringValue = adapter.getString(key);
80
-
81
106
  if (stringValue !== undefined && stringValue.length > 0) {
82
- if (looksLikeGarbled(stringValue)) {
83
- return {
84
- key,
85
- type: 'buffer',
86
- value: Array.from(new TextEncoder().encode(stringValue)),
87
- };
88
- }
107
+ return { key, type: 'string', value: stringValue };
108
+ }
89
109
 
110
+ const bufferValue = adapter.getBuffer(key);
111
+ if (bufferValue !== undefined && bufferValue.byteLength > 0) {
90
112
  return {
91
113
  key,
92
- type: 'string',
93
- value: stringValue,
114
+ type: 'buffer',
115
+ value: Array.from(new Uint8Array(bufferValue)),
94
116
  };
95
117
  }
96
118
 
97
119
  const numberValue = adapter.getNumber(key);
98
120
  if (numberValue !== undefined) {
99
- return {
100
- key,
101
- type: 'number',
102
- value: numberValue,
103
- };
121
+ return { key, type: 'number', value: numberValue };
104
122
  }
105
123
 
106
124
  const booleanValue = adapter.getBoolean(key);
107
125
  if (booleanValue !== undefined) {
108
- return {
109
- key,
110
- type: 'boolean',
111
- value: booleanValue,
112
- };
126
+ return { key, type: 'boolean', value: booleanValue };
113
127
  }
114
128
 
115
- const bufferValue = adapter.getBuffer(key);
116
- if (bufferValue !== undefined) {
117
- return {
118
- key,
119
- type: 'buffer',
120
- value: Array.from(new Uint8Array(bufferValue)),
121
- };
129
+ if (stringValue !== undefined) {
130
+ return { key, type: 'string', value: stringValue };
122
131
  }
123
132
 
124
133
  return undefined;
@@ -127,15 +136,14 @@ const getEntry = (adapter: MMKVAdapter, key: string): StorageEntry | undefined =
127
136
  const setEntry = (adapter: MMKVAdapter, entry: StorageEntry) => {
128
137
  if (entry.type === 'buffer') {
129
138
  adapter.set(entry.key, new Uint8Array(entry.value).buffer);
130
- return;
139
+ } else {
140
+ adapter.set(entry.key, entry.value);
131
141
  }
132
-
133
- adapter.set(entry.key, entry.value);
134
142
  };
135
143
 
136
144
  const getStorageBlacklist = (
137
145
  config: MMKVBlacklistConfig | undefined,
138
- storageId: string
146
+ storageId: string,
139
147
  ) => {
140
148
  if (!config) {
141
149
  return undefined;
@@ -150,7 +158,7 @@ const getStorageBlacklist = (
150
158
 
151
159
  const createStorageBlacklistMatcher = (
152
160
  config: MMKVBlacklistConfig | undefined,
153
- storageId: string
161
+ storageId: string,
154
162
  ) => {
155
163
  if (!(config instanceof RegExp)) {
156
164
  return undefined;
@@ -168,7 +176,10 @@ export const createMMKVStorageAdapter = ({
168
176
  storages,
169
177
  blacklist,
170
178
  }: CreateMMKVStorageAdapterOptions): StorageAdapter => {
171
- const normalizedStorages = normalizeStorages(storages) as Record<string, MMKV>;
179
+ const normalizedStorages = normalizeStorages(storages) as Record<
180
+ string,
181
+ MMKV
182
+ >;
172
183
 
173
184
  const storageNodes: StorageNode[] = Object.entries(normalizedStorages).map(
174
185
  ([storageId, storage]) => {
@@ -191,7 +202,7 @@ export const createMMKVStorageAdapter = ({
191
202
  subscribe: (callback) => mmkv.addOnValueChangedListener(callback),
192
203
  },
193
204
  };
194
- }
205
+ },
195
206
  );
196
207
 
197
208
  return {
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ initialState,
4
+ reduce,
5
+ validate,
6
+ type EditorState,
7
+ } from '../binary-value-editor-state';
8
+
9
+ const hexState = (bytes?: number[]): EditorState =>
10
+ initialState({ initialBytes: bytes, mode: 'hex' });
11
+
12
+ describe('initialState', () => {
13
+ it('defaults to hex mode and empty text when no initialBytes', () => {
14
+ expect(initialState({})).toEqual({
15
+ mode: 'hex',
16
+ text: '',
17
+ bytes: null,
18
+ error: null,
19
+ });
20
+ });
21
+
22
+ it('encodes initial bytes as grouped hex when mode is hex', () => {
23
+ expect(initialState({ initialBytes: [0x89, 0x50, 0x4e, 0x47] })).toEqual({
24
+ mode: 'hex',
25
+ text: '89 50 4E 47',
26
+ bytes: [0x89, 0x50, 0x4e, 0x47],
27
+ error: null,
28
+ });
29
+ });
30
+
31
+ it('encodes initial bytes as base64 when mode is base64', () => {
32
+ expect(
33
+ initialState({ initialBytes: [0x48, 0x69], mode: 'base64' }),
34
+ ).toEqual({
35
+ mode: 'base64',
36
+ text: 'SGk=',
37
+ bytes: [0x48, 0x69],
38
+ error: null,
39
+ });
40
+ });
41
+
42
+ it('treats empty initial bytes the same as no bytes', () => {
43
+ expect(initialState({ initialBytes: [] })).toEqual({
44
+ mode: 'hex',
45
+ text: '',
46
+ bytes: null,
47
+ error: null,
48
+ });
49
+ });
50
+ });
51
+
52
+ describe('reduce: set-text', () => {
53
+ it('parses valid hex into bytes', () => {
54
+ const next = reduce(hexState(), { type: 'set-text', text: '89 50 4E 47' });
55
+ expect(next).toEqual({
56
+ mode: 'hex',
57
+ text: '89 50 4E 47',
58
+ bytes: [0x89, 0x50, 0x4e, 0x47],
59
+ error: null,
60
+ });
61
+ });
62
+
63
+ it('keeps invalid hex text but clears bytes and surfaces the parse error', () => {
64
+ const next = reduce(hexState(), { type: 'set-text', text: 'gg' });
65
+ expect(next).toEqual({
66
+ mode: 'hex',
67
+ text: 'gg',
68
+ bytes: null,
69
+ error: 'Hex input contains invalid characters.',
70
+ });
71
+ });
72
+
73
+ it('keeps trailing-nibble text but reports incomplete bytes', () => {
74
+ const next = reduce(hexState(), { type: 'set-text', text: '89 5' });
75
+ expect(next).toEqual({
76
+ mode: 'hex',
77
+ text: '89 5',
78
+ bytes: null,
79
+ error: 'Hex input must contain complete bytes.',
80
+ });
81
+ });
82
+
83
+ it('surfaces the empty-input error for cleared text', () => {
84
+ const next = reduce(hexState([0x89]), { type: 'set-text', text: '' });
85
+ expect(next).toEqual({
86
+ mode: 'hex',
87
+ text: '',
88
+ bytes: null,
89
+ error: 'Enter at least one byte.',
90
+ });
91
+ });
92
+
93
+ it('does not rewrite text (paste filter is responsible for that)', () => {
94
+ // Even though "DEAD" would canonicalise to "DE AD", set-text must
95
+ // preserve the user's exact text. Only normalize-paste rewrites it.
96
+ const next = reduce(hexState(), { type: 'set-text', text: 'DEAD' });
97
+ expect(next.text).toBe('DEAD');
98
+ expect(next.bytes).toEqual([0xde, 0xad]);
99
+ });
100
+ });
101
+
102
+ describe('reduce: normalize-paste', () => {
103
+ it('rewrites pasted hexdump into canonical grouped hex', () => {
104
+ const pasted =
105
+ '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|';
106
+ const next = reduce(hexState(), { type: 'normalize-paste', text: pasted });
107
+ expect(next.text).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
108
+ expect(next.bytes).toEqual([
109
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
110
+ 0x49, 0x48, 0x44, 0x52,
111
+ ]);
112
+ expect(next.error).toBeNull();
113
+ });
114
+
115
+ it('rewrites pasted base64 into canonical base64', () => {
116
+ const state = initialState({ mode: 'base64' });
117
+ const next = reduce(state, {
118
+ type: 'normalize-paste',
119
+ text: ' SGVs\nbG8= ',
120
+ });
121
+ expect(next.text).toBe('SGVsbG8=');
122
+ expect(next.bytes).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
123
+ });
124
+
125
+ it('keeps the raw pasted text and reports the error when invalid', () => {
126
+ const next = reduce(hexState(), {
127
+ type: 'normalize-paste',
128
+ text: 'oh no',
129
+ });
130
+ expect(next.text).toBe('oh no');
131
+ expect(next.bytes).toBeNull();
132
+ expect(next.error).toBe('Hex input contains invalid characters.');
133
+ });
134
+ });
135
+
136
+ describe('reduce: switch-mode', () => {
137
+ it('converts valid bytes in place when switching hex -> base64', () => {
138
+ const start = reduce(hexState(), { type: 'set-text', text: '48 69' });
139
+ const next = reduce(start, { type: 'switch-mode', mode: 'base64' });
140
+ expect(next).toEqual({
141
+ mode: 'base64',
142
+ text: 'SGk=',
143
+ bytes: [0x48, 0x69],
144
+ error: null,
145
+ });
146
+ });
147
+
148
+ it('converts valid bytes in place when switching base64 -> hex', () => {
149
+ const start = reduce(initialState({ mode: 'base64' }), {
150
+ type: 'set-text',
151
+ text: 'SGk=',
152
+ });
153
+ const next = reduce(start, { type: 'switch-mode', mode: 'hex' });
154
+ expect(next).toEqual({
155
+ mode: 'hex',
156
+ text: '48 69',
157
+ bytes: [0x48, 0x69],
158
+ error: null,
159
+ });
160
+ });
161
+
162
+ it('clears text and error when current input is invalid', () => {
163
+ const invalid = reduce(hexState(), { type: 'set-text', text: '89 5' });
164
+ const next = reduce(invalid, { type: 'switch-mode', mode: 'base64' });
165
+ expect(next).toEqual({
166
+ mode: 'base64',
167
+ text: '',
168
+ bytes: null,
169
+ error: null,
170
+ });
171
+ });
172
+
173
+ it('is a no-op when the mode is unchanged', () => {
174
+ const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
175
+ expect(reduce(state, { type: 'switch-mode', mode: 'hex' })).toBe(state);
176
+ });
177
+ });
178
+
179
+ describe('validate', () => {
180
+ it('passes when bytes are valid and non-empty', () => {
181
+ const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
182
+ expect(validate(state)).toEqual({ ok: true, bytes: [0x89, 0x50] });
183
+ });
184
+
185
+ it('fails with the parse error when input is invalid', () => {
186
+ const state = reduce(hexState(), { type: 'set-text', text: 'gg' });
187
+ expect(validate(state)).toEqual({
188
+ ok: false,
189
+ reason: 'Hex input contains invalid characters.',
190
+ });
191
+ });
192
+
193
+ it('fails with the empty-input message when nothing is entered', () => {
194
+ expect(validate(hexState())).toEqual({
195
+ ok: false,
196
+ reason: 'Enter at least one byte.',
197
+ });
198
+ });
199
+ });
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ base64ToBytes,
4
+ bytesToAsciiPreview,
5
+ bytesToBase64,
6
+ bytesToGroupedHex,
7
+ bytesToHexdump,
8
+ compactBufferPreview,
9
+ hexInputToBytes,
10
+ } from '../binary';
11
+
12
+ // 0x89 'P' 'N' 'G' \r \n 0x1A \n - the canonical PNG signature.
13
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14
+
15
+ describe('bytesToGroupedHex', () => {
16
+ it('returns empty string for empty bytes', () => {
17
+ expect(bytesToGroupedHex([])).toBe('');
18
+ });
19
+
20
+ it('groups under 8 bytes without a double-space separator', () => {
21
+ expect(bytesToGroupedHex([0x89, 0x50])).toBe('89 50');
22
+ });
23
+
24
+ it('inserts a double-space gap between bytes 8 and 9', () => {
25
+ expect(
26
+ bytesToGroupedHex([
27
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
28
+ 0x49, 0x48, 0x44, 0x52,
29
+ ]),
30
+ ).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
31
+ });
32
+
33
+ it('wraps after 16 bytes with a newline', () => {
34
+ const bytes = new Array(20).fill(0xff);
35
+ const out = bytesToGroupedHex(bytes);
36
+ const lines = out.split('\n');
37
+ expect(lines).toHaveLength(2);
38
+ expect(lines[0]).toBe('FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF');
39
+ expect(lines[1]).toBe('FF FF FF FF');
40
+ });
41
+ });
42
+
43
+ describe('bytesToHexdump', () => {
44
+ it('renders offset, grouped hex, and ASCII column', () => {
45
+ const bytes = [
46
+ ...PNG_SIGNATURE,
47
+ 0x00,
48
+ 0x00,
49
+ 0x00,
50
+ 0x0d,
51
+ 0x49,
52
+ 0x48,
53
+ 0x44,
54
+ 0x52,
55
+ ];
56
+ expect(bytesToHexdump(bytes)).toBe(
57
+ '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|',
58
+ );
59
+ });
60
+
61
+ it('uses lowercase 8-digit offsets that increment by 16', () => {
62
+ const bytes = new Array(20).fill(0x41);
63
+ const lines = bytesToHexdump(bytes).split('\n');
64
+ expect(lines[0].startsWith('00000000 ')).toBe(true);
65
+ expect(lines[1].startsWith('00000010 ')).toBe(true);
66
+ });
67
+
68
+ it('pads the hex column on a partial trailing line so ASCII aligns', () => {
69
+ const bytes = [0x41, 0x42, 0x43];
70
+ const line = bytesToHexdump(bytes);
71
+ // Hex section width must be exactly 48 chars before the " |..." block.
72
+ expect(line).toBe(
73
+ '00000000 41 42 43 |ABC|',
74
+ );
75
+ });
76
+
77
+ it('renders non-printable bytes as dots in the ASCII column', () => {
78
+ const bytes = [0x00, 0x09, 0x1f, 0x20, 0x7e, 0x7f, 0xff];
79
+ expect(bytesToHexdump(bytes)).toContain('|... ~..|');
80
+ });
81
+ });
82
+
83
+ describe('bytesToAsciiPreview', () => {
84
+ it('maps printable bytes to characters', () => {
85
+ expect(bytesToAsciiPreview([0x48, 0x69])).toBe('Hi');
86
+ });
87
+
88
+ it('treats bytes outside 0x20..0x7E as "."', () => {
89
+ expect(bytesToAsciiPreview([0x1f, 0x20, 0x7e, 0x7f])).toBe('. ~.');
90
+ });
91
+
92
+ it('treats tab and newline as non-printable', () => {
93
+ expect(bytesToAsciiPreview([0x09, 0x0a, 0x0d])).toBe('...');
94
+ });
95
+ });
96
+
97
+ describe('bytesToBase64 / base64ToBytes round-trip', () => {
98
+ it('encodes simple ASCII to base64 and back', () => {
99
+ const bytes = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello"
100
+ const encoded = bytesToBase64(bytes);
101
+ expect(encoded).toBe('SGVsbG8=');
102
+ const decoded = base64ToBytes(encoded);
103
+ expect(decoded).toEqual({ ok: true, value: bytes });
104
+ });
105
+
106
+ it('round-trips arbitrary bytes including high values', () => {
107
+ const bytes = [0x00, 0xff, 0x89, 0x50, 0x4e, 0x47];
108
+ const decoded = base64ToBytes(bytesToBase64(bytes));
109
+ expect(decoded).toEqual({ ok: true, value: bytes });
110
+ });
111
+
112
+ it('accepts base64 with surrounding and internal whitespace', () => {
113
+ expect(base64ToBytes(' SGVs\nbG8= ')).toEqual({
114
+ ok: true,
115
+ value: [0x48, 0x65, 0x6c, 0x6c, 0x6f],
116
+ });
117
+ });
118
+
119
+ it('rejects empty input with the canonical message', () => {
120
+ expect(base64ToBytes('')).toEqual({
121
+ ok: false,
122
+ error: 'Enter at least one byte.',
123
+ });
124
+ expect(base64ToBytes(' ')).toEqual({
125
+ ok: false,
126
+ error: 'Enter at least one byte.',
127
+ });
128
+ });
129
+
130
+ it('rejects malformed base64', () => {
131
+ expect(base64ToBytes('not*valid*base64')).toEqual({
132
+ ok: false,
133
+ error: 'Base64 input is invalid.',
134
+ });
135
+ });
136
+ });
137
+
138
+ describe('compactBufferPreview', () => {
139
+ it('renders just the size for an empty buffer', () => {
140
+ expect(compactBufferPreview([])).toBe('0 B');
141
+ });
142
+
143
+ it('omits the ellipsis when the buffer fits within maxBytes', () => {
144
+ expect(compactBufferPreview([0x89, 0x50, 0x4e, 0x47])).toBe(
145
+ '89 50 4E 47 4 B',
146
+ );
147
+ });
148
+
149
+ it('includes an ellipsis when truncated', () => {
150
+ expect(compactBufferPreview(new Array(128).fill(0xab))).toBe(
151
+ 'AB AB AB AB AB AB AB AB … 128 B',
152
+ );
153
+ });
154
+
155
+ it('respects a custom maxBytes', () => {
156
+ expect(compactBufferPreview([0x01, 0x02, 0x03], { maxBytes: 2 })).toBe(
157
+ '01 02 … 3 B',
158
+ );
159
+ });
160
+ });
161
+
162
+ describe('hexInputToBytes', () => {
163
+ it('parses continuous hex', () => {
164
+ expect(hexInputToBytes('deadbeef')).toEqual({
165
+ ok: true,
166
+ value: [0xde, 0xad, 0xbe, 0xef],
167
+ });
168
+ });
169
+
170
+ it('parses grouped hex', () => {
171
+ expect(hexInputToBytes('DE AD BE EF')).toEqual({
172
+ ok: true,
173
+ value: [0xde, 0xad, 0xbe, 0xef],
174
+ });
175
+ });
176
+
177
+ it('parses multiline grouped hex', () => {
178
+ expect(
179
+ hexInputToBytes(
180
+ '89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52\n00 00 00 20 00 00 00 20',
181
+ ),
182
+ ).toEqual({
183
+ ok: true,
184
+ value: [
185
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
186
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20,
187
+ ],
188
+ });
189
+ });
190
+
191
+ it('parses pasted hexdump rows, stripping offsets and ASCII column', () => {
192
+ const pasted = [
193
+ '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|',
194
+ '00000010 00 00 00 20 00 00 00 20 08 06 00 00 00 73 7A 7A |... ... .....szz|',
195
+ ].join('\n');
196
+ expect(hexInputToBytes(pasted)).toEqual({
197
+ ok: true,
198
+ value: [
199
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
200
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20,
201
+ 0x08, 0x06, 0x00, 0x00, 0x00, 0x73, 0x7a, 0x7a,
202
+ ],
203
+ });
204
+ });
205
+
206
+ it('accepts colon-separated hexdump offsets', () => {
207
+ expect(hexInputToBytes('00000000: 89 50 4E 47')).toEqual({
208
+ ok: true,
209
+ value: [0x89, 0x50, 0x4e, 0x47],
210
+ });
211
+ });
212
+
213
+ it('strips "0x" prefixes', () => {
214
+ expect(hexInputToBytes('0xDE 0xAD 0xBE 0xEF')).toEqual({
215
+ ok: true,
216
+ value: [0xde, 0xad, 0xbe, 0xef],
217
+ });
218
+ });
219
+
220
+ it('tolerates mixed case', () => {
221
+ expect(hexInputToBytes('De Ad bE eF')).toEqual({
222
+ ok: true,
223
+ value: [0xde, 0xad, 0xbe, 0xef],
224
+ });
225
+ });
226
+
227
+ it('rejects empty input with the canonical message', () => {
228
+ expect(hexInputToBytes('')).toEqual({
229
+ ok: false,
230
+ error: 'Enter at least one byte.',
231
+ });
232
+ expect(hexInputToBytes(' \n ')).toEqual({
233
+ ok: false,
234
+ error: 'Enter at least one byte.',
235
+ });
236
+ });
237
+
238
+ it('rejects non-hex characters', () => {
239
+ expect(hexInputToBytes('DE AD GG')).toEqual({
240
+ ok: false,
241
+ error: 'Hex input contains invalid characters.',
242
+ });
243
+ });
244
+
245
+ it('rejects odd hex digit count', () => {
246
+ expect(hexInputToBytes('DEAD B')).toEqual({
247
+ ok: false,
248
+ error: 'Hex input must contain complete bytes.',
249
+ });
250
+ });
251
+ });