Skip to content

Commit b94e91f

Browse files
guguclaude
andcommitted
Route binary widgets through shared byte-array helpers
Introduce frontend/src/app/lib/binary.ts with parseBinaryValue, stringToBytes, hexStringToBytes, bytesToHex, and toBufferJson. The edit, view, and table-display widgets now parse any incoming value (string, Buffer-JSON, Uint8Array) to a byte array first and derive the hex display from bytes. Strings received from the server are treated as latin1 binary strings (one char = one byte) rather than hex, so pg's default bytea-as-string output decodes correctly. Hex input typed into the edit or filter widgets still goes through hexStringToBytes for user-facing validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 15f25eb commit b94e91f

10 files changed

Lines changed: 154 additions & 84 deletions

File tree

frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,18 @@ describe('BinaryFilterComponent', () => {
2626
expect(component.hexValue).toBe('');
2727
});
2828

29-
it('populates hexValue from the @Input value on init', () => {
29+
it('normalizes the incoming hex value through bytes on init', () => {
3030
component.value = '48656c6c6f';
3131
component.ngOnInit();
3232
expect(component.hexValue).toBe('48656c6c6f');
3333
});
3434

35+
it('drops a malformed incoming hex value to empty on init', () => {
36+
component.value = 'zz';
37+
component.ngOnInit();
38+
expect(component.hexValue).toBe('');
39+
});
40+
3541
it('emits the hex string and current comparator on hex change', () => {
3642
vi.spyOn(component.onFieldChange, 'emit');
3743
vi.spyOn(component.onComparatorChange, 'emit');

frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
44
import { MatInputModule } from '@angular/material/input';
55
import { MatSelectModule } from '@angular/material/select';
66
import { HexValidationDirective } from 'src/app/directives/hexValidator.directive';
7+
import { bytesToHex, hexStringToBytes } from 'src/app/lib/binary';
78
import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component';
89

910
export type BinaryFilterMode = 'eq' | 'contains' | 'startswith' | 'empty';
@@ -22,7 +23,7 @@ export class BinaryFilterComponent extends BaseFilterFieldComponent implements O
2223
public hexValue = '';
2324

2425
override ngOnInit(): void {
25-
if (this.value) this.hexValue = this.value;
26+
this.hexValue = bytesToHex(hexStringToBytes(this.value ?? ''));
2627
}
2728

2829
ngAfterViewInit(): void {

frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,19 @@ describe('BinaryEditComponent', () => {
2121
expect(component).toBeTruthy();
2222
});
2323

24-
it('converts an incoming hex string to Buffer-JSON on init', () => {
24+
it('parses a server string as char-code-per-byte and emits Buffer-JSON on init', () => {
2525
vi.spyOn(component.onFieldChange, 'emit');
26-
fixture.componentRef.setInput('value', '48656c6c6f');
26+
fixture.componentRef.setInput('value', 'Hello');
2727
component.ngOnInit();
28+
// 'Hello' -> bytes 48 65 6c 6c 6f -> hex '48656c6c6f'
2829
expect(component.hexData).toBe('48656c6c6f');
2930
expect(component.onFieldChange.emit).toHaveBeenCalledWith({
3031
type: 'Buffer',
3132
data: [0x48, 0x65, 0x6c, 0x6c, 0x6f],
3233
});
3334
});
3435

35-
it('re-emits an incoming Buffer-JSON value unchanged on init', () => {
36+
it('re-emits an incoming Buffer-JSON value as Buffer-JSON on init', () => {
3637
vi.spyOn(component.onFieldChange, 'emit');
3738
fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] });
3839
component.ngOnInit();
@@ -51,7 +52,7 @@ describe('BinaryEditComponent', () => {
5152
expect(component.onFieldChange.emit).toHaveBeenCalledWith(null);
5253
});
5354

54-
it('emits Buffer-JSON on valid hex change', () => {
55+
it('emits Buffer-JSON when the user types valid hex', () => {
5556
vi.spyOn(component.onFieldChange, 'emit');
5657
component.hexData = '48656c6c6f';
5758
component.onHexChange();
@@ -69,7 +70,7 @@ describe('BinaryEditComponent', () => {
6970
expect(component.onFieldChange.emit).toHaveBeenCalledWith(null);
7071
});
7172

72-
it('marks invalid and emits raw string when hex is malformed', () => {
73+
it('marks invalid and emits raw string when the user types malformed hex', () => {
7374
vi.spyOn(component.onFieldChange, 'emit');
7475
component.hexData = 'zz';
7576
component.onHexChange();

frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { FormsModule } from '@angular/forms';
33
import { MatFormFieldModule } from '@angular/material/form-field';
44
import { MatInputModule } from '@angular/material/input';
55
import { HexValidationDirective } from 'src/app/directives/hexValidator.directive';
6+
import { BinaryBufferJson, bytesToHex, hexStringToBytes, parseBinaryValue, toBufferJson } from 'src/app/lib/binary';
67
import { hexValidation } from 'src/app/validators/hex.validator';
78
import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component';
89

9-
export type BinaryBufferJson = { type: 'Buffer'; data: number[] };
10-
1110
@Component({
1211
selector: 'app-edit-binary',
1312
templateUrl: './binary.component.html',
@@ -24,7 +23,7 @@ export class BinaryEditComponent extends BaseEditFieldComponent implements OnIni
2423

2524
ngOnInit(): void {
2625
super.ngOnInit();
27-
this.hexData = this.normalizeIncomingValueToHex(this.value());
26+
this.hexData = bytesToHex(parseBinaryValue(this.value()));
2827
this.emitCurrentValue();
2928
}
3029

@@ -42,26 +41,6 @@ export class BinaryEditComponent extends BaseEditFieldComponent implements OnIni
4241
this.onFieldChange.emit(this.hexData);
4342
return;
4443
}
45-
this.onFieldChange.emit({ type: 'Buffer', data: hexToBytes(this.hexData) });
46-
}
47-
48-
private normalizeIncomingValueToHex(value: unknown): string {
49-
if (value == null) return '';
50-
if (typeof value === 'string') return value;
51-
if (typeof value === 'object' && 'data' in (value as Record<string, unknown>)) {
52-
const data = (value as { data: unknown }).data;
53-
if (Array.isArray(data)) {
54-
return (data as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
55-
}
56-
}
57-
return '';
58-
}
59-
}
60-
61-
function hexToBytes(hex: string): number[] {
62-
const bytes: number[] = [];
63-
for (let i = 0; i < hex.length; i += 2) {
64-
bytes.push(parseInt(hex.substring(i, i + 2), 16));
44+
this.onFieldChange.emit(toBufferJson(hexStringToBytes(this.hexData)));
6545
}
66-
return bytes;
6746
}

frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,26 @@ describe('BinaryRecordViewComponent', () => {
2525
expect(component.displayText()).toBe('\u2014');
2626
});
2727

28-
it('shows hex as-is when short', () => {
29-
fixture.componentRef.setInput('value', 'abcdef');
28+
it('parses a server string as char-code-per-byte', () => {
29+
fixture.componentRef.setInput('value', 'Hel');
3030
fixture.detectChanges();
31-
expect(component.displayText()).toBe('abcdef');
32-
expect(component.isTruncated()).toBe(false);
31+
expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]);
32+
expect(component.hexValue()).toBe('48656c');
3333
});
3434

35-
it('truncates long hex with ellipsis', () => {
36-
const longHex = 'a'.repeat(120);
37-
fixture.componentRef.setInput('value', longHex);
35+
it('parses a Buffer-JSON value to a byte array', () => {
36+
fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] });
3837
fixture.detectChanges();
39-
expect(component.displayText()).toBe('a'.repeat(80) + '\u2026');
40-
expect(component.isTruncated()).toBe(true);
38+
expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]);
39+
expect(component.hexValue()).toBe('48656c');
4140
});
4241

43-
it('converts Buffer-JSON to hex', () => {
44-
fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] });
42+
it('truncates long hex with ellipsis', () => {
43+
// 40 bytes of 0xaa → 80 hex chars, just at the limit; bump to 50 bytes to trigger truncation.
44+
fixture.componentRef.setInput('value', '\u00aa'.repeat(50));
4545
fixture.detectChanges();
46-
expect(component.hexValue()).toBe('48656c');
46+
expect(component.hexValue()).toBe('aa'.repeat(50));
47+
expect(component.isTruncated()).toBe(true);
48+
expect(component.displayText()).toBe('aa'.repeat(40) + '\u2026');
4749
});
4850
});

frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Component, computed } from '@angular/core';
33
import { MatButtonModule } from '@angular/material/button';
44
import { MatIconModule } from '@angular/material/icon';
55
import { MatTooltipModule } from '@angular/material/tooltip';
6+
import { bytesToHex, parseBinaryValue } from 'src/app/lib/binary';
67
import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component';
78

89
const MAX_DISPLAY_LENGTH = 80;
@@ -14,7 +15,8 @@ const MAX_DISPLAY_LENGTH = 80;
1415
imports: [ClipboardModule, MatButtonModule, MatIconModule, MatTooltipModule],
1516
})
1617
export class BinaryRecordViewComponent extends BaseRecordViewFieldComponent {
17-
public readonly hexValue = computed(() => toHex(this.value()));
18+
public readonly bytes = computed(() => parseBinaryValue(this.value()));
19+
public readonly hexValue = computed(() => bytesToHex(this.bytes()));
1820

1921
public readonly displayText = computed(() => {
2022
const hex = this.hexValue();
@@ -24,15 +26,3 @@ export class BinaryRecordViewComponent extends BaseRecordViewFieldComponent {
2426

2527
public readonly isTruncated = computed(() => this.hexValue().length > MAX_DISPLAY_LENGTH);
2628
}
27-
28-
function toHex(value: unknown): string {
29-
if (value == null) return '';
30-
if (typeof value === 'string') return value;
31-
if (typeof value === 'object' && 'data' in (value as Record<string, unknown>)) {
32-
const data = (value as { data: unknown }).data;
33-
if (Array.isArray(data)) {
34-
return (data as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
35-
}
36-
}
37-
return '';
38-
}

frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,23 @@ describe('BinaryDisplayComponent', () => {
2525
expect(component.displayText()).toBe('\u2014');
2626
});
2727

28-
it('shows hex as-is when short', () => {
29-
fixture.componentRef.setInput('value', 'abcdef');
28+
it('parses a server string as char-code-per-byte', () => {
29+
fixture.componentRef.setInput('value', 'Hel');
3030
fixture.detectChanges();
31-
expect(component.displayText()).toBe('abcdef');
32-
});
33-
34-
it('truncates long hex at 20 chars', () => {
35-
const longHex = 'a'.repeat(30);
36-
fixture.componentRef.setInput('value', longHex);
37-
fixture.detectChanges();
38-
expect(component.displayText()).toBe('a'.repeat(20) + '\u2026');
31+
expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]);
32+
expect(component.hexValue()).toBe('48656c');
3933
});
4034

41-
it('converts Buffer-JSON to hex', () => {
35+
it('parses a Buffer-JSON value to a byte array', () => {
4236
fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] });
4337
fixture.detectChanges();
38+
expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]);
4439
expect(component.hexValue()).toBe('48656c');
4540
});
4641

47-
it('exposes the full hex via hexValue', () => {
48-
const longHex = 'a'.repeat(60);
49-
fixture.componentRef.setInput('value', longHex);
42+
it('truncates long hex at 20 chars', () => {
43+
fixture.componentRef.setInput('value', '\u00aa'.repeat(30));
5044
fixture.detectChanges();
51-
expect(component.hexValue()).toBe(longHex);
45+
expect(component.displayText()).toBe('aa'.repeat(10) + '\u2026');
5246
});
5347
});

frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Component, computed } from '@angular/core';
33
import { MatButtonModule } from '@angular/material/button';
44
import { MatIconModule } from '@angular/material/icon';
55
import { MatTooltipModule } from '@angular/material/tooltip';
6+
import { bytesToHex, parseBinaryValue } from 'src/app/lib/binary';
67
import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component';
78

89
const MAX_DISPLAY_LENGTH = 20;
@@ -14,23 +15,12 @@ const MAX_DISPLAY_LENGTH = 20;
1415
imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule],
1516
})
1617
export class BinaryDisplayComponent extends BaseTableDisplayFieldComponent {
17-
public readonly hexValue = computed(() => toHex(this.value()));
18+
public readonly bytes = computed(() => parseBinaryValue(this.value()));
19+
public readonly hexValue = computed(() => bytesToHex(this.bytes()));
1820

1921
public readonly displayText = computed(() => {
2022
const hex = this.hexValue();
2123
if (!hex) return '\u2014';
2224
return hex.length > MAX_DISPLAY_LENGTH ? hex.substring(0, MAX_DISPLAY_LENGTH) + '\u2026' : hex;
2325
});
2426
}
25-
26-
function toHex(value: unknown): string {
27-
if (value == null) return '';
28-
if (typeof value === 'string') return value;
29-
if (typeof value === 'object' && 'data' in (value as Record<string, unknown>)) {
30-
const data = (value as { data: unknown }).data;
31-
if (Array.isArray(data)) {
32-
return (data as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
33-
}
34-
}
35-
return '';
36-
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { bytesToHex, hexStringToBytes, parseBinaryValue, stringToBytes, toBufferJson } from './binary';
2+
3+
describe('binary helpers', () => {
4+
describe('parseBinaryValue', () => {
5+
it('returns empty for null/undefined/empty', () => {
6+
expect(parseBinaryValue(null)).toEqual([]);
7+
expect(parseBinaryValue(undefined)).toEqual([]);
8+
expect(parseBinaryValue('')).toEqual([]);
9+
});
10+
11+
it('parses a string as char-code-per-byte', () => {
12+
expect(parseBinaryValue('Hello')).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
13+
});
14+
15+
it('truncates char codes above 0xff to a byte', () => {
16+
expect(parseBinaryValue('\u00ff\u0100')).toEqual([0xff, 0x00]);
17+
});
18+
19+
it('extracts data from a Buffer-JSON value', () => {
20+
expect(parseBinaryValue({ type: 'Buffer', data: [0x48, 0x65] })).toEqual([0x48, 0x65]);
21+
});
22+
23+
it('extracts data from a Uint8Array', () => {
24+
expect(parseBinaryValue(new Uint8Array([1, 2, 3]))).toEqual([1, 2, 3]);
25+
});
26+
});
27+
28+
describe('stringToBytes', () => {
29+
it('maps each char to its 8-bit code', () => {
30+
expect(stringToBytes('Hi')).toEqual([0x48, 0x69]);
31+
});
32+
});
33+
34+
describe('hexStringToBytes', () => {
35+
it('parses even-length hex', () => {
36+
expect(hexStringToBytes('48656c6c6f')).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
37+
});
38+
39+
it('left-pads odd-length hex', () => {
40+
expect(hexStringToBytes('abc')).toEqual([0x0a, 0xbc]);
41+
});
42+
43+
it('returns empty for malformed hex', () => {
44+
expect(hexStringToBytes('zz')).toEqual([]);
45+
});
46+
47+
it('returns empty for empty input', () => {
48+
expect(hexStringToBytes('')).toEqual([]);
49+
});
50+
});
51+
52+
describe('bytesToHex', () => {
53+
it('formats bytes with zero-padded lowercase hex', () => {
54+
expect(bytesToHex([0x01, 0xab, 0x00])).toBe('01ab00');
55+
});
56+
57+
it('returns empty string for empty array', () => {
58+
expect(bytesToHex([])).toBe('');
59+
});
60+
});
61+
62+
describe('toBufferJson', () => {
63+
it('wraps bytes in Buffer-JSON shape', () => {
64+
expect(toBufferJson([1, 2])).toEqual({ type: 'Buffer', data: [1, 2] });
65+
});
66+
});
67+
});

frontend/src/app/lib/binary.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export type BinaryBufferJson = { type: 'Buffer'; data: number[] };
2+
3+
export function parseBinaryValue(value: unknown): number[] {
4+
if (value == null || value === '') return [];
5+
if (value instanceof Uint8Array) return Array.from(value);
6+
if (typeof value === 'string') return stringToBytes(value);
7+
if (typeof value === 'object') {
8+
const data = (value as { data?: unknown }).data;
9+
if (Array.isArray(data)) return (data as number[]).slice();
10+
}
11+
return [];
12+
}
13+
14+
export function stringToBytes(str: string): number[] {
15+
const bytes: number[] = [];
16+
for (let i = 0; i < str.length; i++) {
17+
bytes.push(str.charCodeAt(i) & 0xff);
18+
}
19+
return bytes;
20+
}
21+
22+
export function hexStringToBytes(hex: string): number[] {
23+
if (!hex) return [];
24+
const normalized = hex.length % 2 === 0 ? hex : `0${hex}`;
25+
const bytes: number[] = [];
26+
for (let i = 0; i < normalized.length; i += 2) {
27+
const byte = parseInt(normalized.substring(i, i + 2), 16);
28+
if (Number.isNaN(byte)) return [];
29+
bytes.push(byte);
30+
}
31+
return bytes;
32+
}
33+
34+
export function bytesToHex(bytes: number[]): string {
35+
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
36+
}
37+
38+
export function toBufferJson(bytes: number[]): BinaryBufferJson {
39+
return { type: 'Buffer', data: bytes };
40+
}

0 commit comments

Comments
 (0)