Skip to content

Commit fde41ad

Browse files
committed
fix: hex editor uses local state with commit-on-focus-loss and add missing tests
1 parent 5778d5d commit fde41ad

2 files changed

Lines changed: 208 additions & 79 deletions

File tree

TablePro/Views/RightSidebar/EditableFieldView.swift

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ struct EditableFieldView: View {
2828
@FocusState private var isFocused: Bool
2929
@State private var isHovered = false
3030
@State private var isSetPopoverPresented = false
31+
@State private var hexEditText = ""
3132

3233
private var placeholderText: String {
3334
if hasMultipleValues {
@@ -103,33 +104,49 @@ struct EditableFieldView: View {
103104

104105
private var blobHexEditor: some View {
105106
VStack(alignment: .leading, spacing: 2) {
106-
TextField(
107-
"Hex bytes",
108-
text: Binding(
109-
get: {
110-
BlobFormattingService.shared.format(value, for: .edit) ?? ""
111-
},
112-
set: { newHex in
113-
if let raw = BlobFormattingService.shared.parseHex(newHex) {
114-
value = raw
115-
}
107+
TextField("Hex bytes", text: $hexEditText, axis: .vertical)
108+
.textFieldStyle(.roundedBorder)
109+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced))
110+
.lineLimit(3...8)
111+
.focused($isFocused)
112+
.onAppear {
113+
hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? ""
114+
}
115+
.onChange(of: value) {
116+
if !isFocused {
117+
hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? ""
116118
}
117-
),
118-
axis: .vertical
119-
)
120-
.textFieldStyle(.roundedBorder)
121-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced))
122-
.lineLimit(3...8)
123-
.focused($isFocused)
119+
}
120+
.onChange(of: isFocused) {
121+
if !isFocused {
122+
commitHexEdit()
123+
}
124+
}
124125

125-
if let byteCount = value.data(using: .isoLatin1)?.count, byteCount > 0 {
126-
Text("\(byteCount) bytes")
127-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny))
128-
.foregroundStyle(.tertiary)
126+
HStack(spacing: 4) {
127+
if let byteCount = value.data(using: .isoLatin1)?.count, byteCount > 0 {
128+
Text("\(byteCount) bytes")
129+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny))
130+
.foregroundStyle(.tertiary)
131+
}
132+
133+
if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty {
134+
Text("Invalid hex")
135+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny))
136+
.foregroundStyle(.red)
137+
}
129138
}
130139
}
131140
}
132141

142+
private func commitHexEdit() {
143+
if let raw = BlobFormattingService.shared.parseHex(hexEditText) {
144+
value = raw
145+
} else {
146+
hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? ""
147+
}
148+
}
149+
133150
private var booleanPicker: some View {
134151
dropdownField(label: normalizeBooleanValue(value) == "1" ? "true" : "false") {
135152
Button("true") { value = "1" }

TableProTests/Extensions/StringHexDumpTests.swift

Lines changed: 170 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,76 +3,188 @@
33
// TableProTests
44
//
55

6-
import XCTest
6+
import Foundation
7+
import Testing
8+
79
@testable import TablePro
810

9-
final class StringHexDumpTests: XCTestCase {
11+
@Suite("String+HexDump")
12+
struct StringHexDumpTests {
13+
// MARK: - Hex Dump
1014

11-
func testEmptyStringReturnsNil() {
12-
XCTAssertNil("".formattedAsHexDump())
15+
@Test("Empty string returns nil")
16+
func emptyStringReturnsNil() {
17+
#expect("".formattedAsHexDump() == nil)
1318
}
1419

15-
func testBasicASCII() {
20+
@Test("Basic ASCII produces correct hex and ASCII column")
21+
func basicASCII() {
1622
let result = "Hello".formattedAsHexDump()
17-
XCTAssertNotNil(result)
18-
// "Hello" = 48 65 6C 6C 6F
19-
XCTAssertTrue(result!.contains("48 65 6C 6C 6F"))
20-
XCTAssertTrue(result!.contains("|Hello|"))
21-
}
22-
23-
func testFullLine() {
24-
// 16 bytes: "0123456789ABCDEF"
25-
let input = "0123456789ABCDEF"
26-
let result = input.formattedAsHexDump()!
27-
// Should have offset 00000000
28-
XCTAssertTrue(result.hasPrefix("00000000"))
29-
// Should contain ASCII representation
30-
XCTAssertTrue(result.contains("|0123456789ABCDEF|"))
31-
}
32-
33-
func testMultipleLines() {
34-
// 20 bytes = 1 full line (16) + 1 partial line (4)
35-
let input = "ABCDEFGHIJKLMNOPQRST"
36-
let result = input.formattedAsHexDump()!
37-
let lines = result.split(separator: "\n")
38-
XCTAssertEqual(lines.count, 2)
39-
XCTAssertTrue(lines[0].hasPrefix("00000000"))
40-
XCTAssertTrue(lines[1].hasPrefix("00000010"))
41-
}
42-
43-
func testNonPrintableCharsShowAsDots() {
44-
// Create string with non-printable characters via isoLatin1
23+
#expect(result != nil)
24+
#expect(result?.contains("48 65 6C 6C 6F") == true)
25+
#expect(result?.contains("|Hello|") == true)
26+
}
27+
28+
@Test("Full 16-byte line has correct offset and ASCII")
29+
func fullLine() {
30+
let result = "0123456789ABCDEF".formattedAsHexDump()
31+
#expect(result?.hasPrefix("00000000") == true)
32+
#expect(result?.contains("|0123456789ABCDEF|") == true)
33+
}
34+
35+
@Test("Multiple lines have correct offsets")
36+
func multipleLines() {
37+
let result = "ABCDEFGHIJKLMNOPQRST".formattedAsHexDump()
38+
let lines = result?.split(separator: "\n") ?? []
39+
#expect(lines.count == 2)
40+
#expect(lines[0].hasPrefix("00000000"))
41+
#expect(lines[1].hasPrefix("00000010"))
42+
}
43+
44+
@Test("Non-printable characters show as dots in ASCII column")
45+
func nonPrintableCharsShowAsDots() {
4546
let bytes: [UInt8] = [0x00, 0x01, 0x02, 0x41, 0x42, 0x7F, 0xFF]
46-
let input = String(bytes: bytes, encoding: .isoLatin1)!
47-
let result = input.formattedAsHexDump()!
48-
// 0x00, 0x01, 0x02 → dots; 0x41, 0x42 → "AB"; 0x7F, 0xFF → dots
49-
XCTAssertTrue(result.contains("|...AB..|"))
47+
guard let input = String(bytes: bytes, encoding: .isoLatin1) else {
48+
Issue.record("Failed to create isoLatin1 string")
49+
return
50+
}
51+
let result = input.formattedAsHexDump()
52+
#expect(result?.contains("|...AB..|") == true)
5053
}
5154

52-
func testTruncation() {
53-
// Create a string larger than maxBytes
55+
@Test("Truncation adds summary line")
56+
func truncation() {
5457
let input = String(repeating: "A", count: 100)
55-
let result = input.formattedAsHexDump(maxBytes: 32)!
56-
XCTAssertTrue(result.contains("truncated"))
57-
XCTAssertTrue(result.contains("100 bytes total"))
58-
// Should only have 2 full lines (32 bytes / 16) + truncation line
59-
let lines = result.split(separator: "\n")
60-
XCTAssertEqual(lines.count, 3)
58+
let result = input.formattedAsHexDump(maxBytes: 32)
59+
#expect(result?.contains("truncated") == true)
60+
#expect(result?.contains("100 bytes total") == true)
61+
let lines = result?.split(separator: "\n") ?? []
62+
#expect(lines.count == 3)
6163
}
6264

63-
func testOffsetFormatting() {
65+
@Test("Offset formatting across multiple lines")
66+
func offsetFormatting() {
6467
let input = String(repeating: "X", count: 48)
65-
let result = input.formattedAsHexDump()!
66-
let lines = result.split(separator: "\n")
67-
XCTAssertEqual(lines.count, 3)
68-
XCTAssertTrue(lines[0].hasPrefix("00000000"))
69-
XCTAssertTrue(lines[1].hasPrefix("00000010"))
70-
XCTAssertTrue(lines[2].hasPrefix("00000020"))
71-
}
72-
73-
func testSingleByte() {
74-
let result = "A".formattedAsHexDump()!
75-
XCTAssertTrue(result.contains("41"))
76-
XCTAssertTrue(result.contains("|A|"))
68+
let lines = input.formattedAsHexDump()?.split(separator: "\n") ?? []
69+
#expect(lines.count == 3)
70+
#expect(lines[0].hasPrefix("00000000"))
71+
#expect(lines[1].hasPrefix("00000010"))
72+
#expect(lines[2].hasPrefix("00000020"))
73+
}
74+
75+
@Test("Single byte")
76+
func singleByte() {
77+
let result = "A".formattedAsHexDump()
78+
#expect(result?.contains("41") == true)
79+
#expect(result?.contains("|A|") == true)
80+
}
81+
82+
// MARK: - Compact Hex
83+
84+
@Test("Compact hex basic ASCII")
85+
func compactHexBasic() {
86+
#expect("Hello".formattedAsCompactHex() == "0x48656C6C6F")
87+
}
88+
89+
@Test("Compact hex empty string returns nil")
90+
func compactHexEmpty() {
91+
#expect("".formattedAsCompactHex() == nil)
92+
}
93+
94+
@Test("Compact hex truncation adds ellipsis")
95+
func compactHexTruncation() {
96+
let input = String(repeating: "A", count: 100)
97+
#expect(input.formattedAsCompactHex(maxBytes: 4) == "0x41414141…")
98+
}
99+
100+
@Test("Compact hex non-printable bytes")
101+
func compactHexNonPrintable() {
102+
let bytes: [UInt8] = [0x00, 0xFF]
103+
guard let input = String(bytes: bytes, encoding: .isoLatin1) else {
104+
Issue.record("Failed to create isoLatin1 string")
105+
return
106+
}
107+
#expect(input.formattedAsCompactHex() == "0x00FF")
108+
}
109+
110+
// MARK: - Editable Hex
111+
112+
@Test("Editable hex basic ASCII")
113+
func editableHexBasic() {
114+
#expect("Hello".formattedAsEditableHex() == "48 65 6C 6C 6F")
115+
}
116+
117+
@Test("Editable hex empty string returns nil")
118+
func editableHexEmpty() {
119+
#expect("".formattedAsEditableHex() == nil)
120+
}
121+
122+
@Test("Editable hex non-printable bytes")
123+
func editableHexNonPrintable() {
124+
let bytes: [UInt8] = [0x00, 0x01, 0xFF]
125+
guard let input = String(bytes: bytes, encoding: .isoLatin1) else {
126+
Issue.record("Failed to create isoLatin1 string")
127+
return
128+
}
129+
#expect(input.formattedAsEditableHex() == "00 01 FF")
130+
}
131+
132+
@Test("Editable hex truncation adds ellipsis")
133+
func editableHexTruncation() {
134+
let input = String(repeating: "A", count: 100)
135+
let result = input.formattedAsEditableHex(maxBytes: 3)
136+
#expect(result?.hasPrefix("41 41 41") == true)
137+
#expect(result?.hasSuffix("") == true)
138+
}
139+
140+
// MARK: - Parse Hex
141+
142+
@Test("Parse space-separated hex")
143+
@MainActor
144+
func parseHexSpaceSeparated() {
145+
#expect(BlobFormattingService.shared.parseHex("48 65 6C 6C 6F") == "Hello")
146+
}
147+
148+
@Test("Parse continuous hex")
149+
@MainActor
150+
func parseHexContinuous() {
151+
#expect(BlobFormattingService.shared.parseHex("48656C6C6F") == "Hello")
152+
}
153+
154+
@Test("Parse hex with 0x prefix")
155+
@MainActor
156+
func parseHexWithPrefix() {
157+
#expect(BlobFormattingService.shared.parseHex("0x48656C6C6F") == "Hello")
158+
}
159+
160+
@Test("Parse hex rejects odd-length input")
161+
@MainActor
162+
func parseHexInvalidOddLength() {
163+
#expect(BlobFormattingService.shared.parseHex("486") == nil)
164+
}
165+
166+
@Test("Parse hex rejects invalid characters")
167+
@MainActor
168+
func parseHexInvalidChars() {
169+
#expect(BlobFormattingService.shared.parseHex("ZZZZ") == nil)
170+
}
171+
172+
@Test("Parse hex rejects empty string")
173+
@MainActor
174+
func parseHexEmpty() {
175+
#expect(BlobFormattingService.shared.parseHex("") == nil)
176+
}
177+
178+
@Test("Round-trip: raw → editable hex → parse back to raw")
179+
@MainActor
180+
func parseHexRoundTrip() {
181+
let bytes: [UInt8] = [0x00, 0x01, 0x7F, 0x80, 0xFF]
182+
guard let original = String(bytes: bytes, encoding: .isoLatin1),
183+
let hex = original.formattedAsEditableHex(),
184+
let roundTripped = BlobFormattingService.shared.parseHex(hex) else {
185+
Issue.record("Round-trip conversion failed")
186+
return
187+
}
188+
#expect(roundTripped == original)
77189
}
78190
}

0 commit comments

Comments
 (0)