From df784d4b41f6d791b9901e90b93528ade1c91268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 11 May 2026 16:40:37 +0700 Subject: [PATCH] test(regression): cover MariaDB type name resolution, RowDisplayCache eviction, and PluginCellValue.asText contract --- .../MariaDBPluginConnection.swift | 29 ++++- .../Core/DataGrid/RowDisplayCacheTests.swift | 116 +++++++++++++++++ .../Plugins/MariaDBTypeNameTests.swift | 120 ++++++++++++++++++ .../Plugins/PluginCellValueSortKeyTests.swift | 34 +++++ 4 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 TableProTests/Core/DataGrid/RowDisplayCacheTests.swift create mode 100644 TableProTests/Plugins/MariaDBTypeNameTests.swift diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 04a344991..3f10d56f7 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -12,10 +12,10 @@ import OSLog import TableProPluginKit // MySQL/MariaDB field flag and charset constants -private let mysqlBinaryFlag: UInt = 0x0080 -private let mysqlEnumFlag: UInt = 0x0100 -private let mysqlSetFlag: UInt = 0x0800 -private let mysqlBinaryCharset: UInt32 = 63 +internal let mysqlBinaryFlag: UInt = 0x0080 +internal let mysqlEnumFlag: UInt = 0x0100 +internal let mysqlSetFlag: UInt = 0x0800 +internal let mysqlBinaryCharset: UInt32 = 63 private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginConnection") @@ -93,11 +93,28 @@ func mysqlTypeToString(_ fieldPtr: UnsafePointer) -> String { if (flags & mysqlEnumFlag) != 0 { return "ENUM" } if (flags & mysqlSetFlag) != 0 { return "SET" } + return mariaDBTypeName( + typeRaw: field.type.rawValue, + flags: flags, + charsetnr: field.charsetnr, + length: field.length + ) +} + +/// Pure mapping from raw MySQL/MariaDB field type code + flags to TablePro's +/// column-type-name string. Separated from `mysqlTypeToString` so it can be +/// unit-tested without an actual `MYSQL_FIELD` struct. +internal func mariaDBTypeName( + typeRaw: UInt32, + flags: UInt, + charsetnr: UInt32, + length: UInt +) -> String { // Binary flag alone is insufficient — MariaDB sets it on text columns with // binary collation (e.g. utf8mb4_bin for JSON). Only charset 63 is truly binary. - let isBinary = (flags & mysqlBinaryFlag) != 0 && field.charsetnr == mysqlBinaryCharset + let isBinary = (flags & mysqlBinaryFlag) != 0 && charsetnr == mysqlBinaryCharset - switch field.type.rawValue { + switch typeRaw { case 0: return "DECIMAL" case 1: return "TINYINT" case 2: return "SMALLINT" diff --git a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift new file mode 100644 index 000000000..d62ca3b9c --- /dev/null +++ b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift @@ -0,0 +1,116 @@ +// +// RowDisplayCacheTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("RowDisplayCache") +@MainActor +struct RowDisplayCacheTests { + private func makeBox(_ values: [String?]) -> RowDisplayBox { + RowDisplayBox(ContiguousArray(values)) + } + + private func cost(of values: [String?]) -> Int { + var total = 0 + for v in values { + if let s = v { total &+= s.utf8.count } + } + return total + } + + @Test("Empty cache returns nil for any lookup") + func emptyLookup() { + let cache = RowDisplayCache() + #expect(cache.box(forID: .existing(0)) == nil) + #expect(cache.box(forID: .existing(100)) == nil) + } + + @Test("Inserted box is retrievable") + func basicSetGet() { + let cache = RowDisplayCache() + let id = RowID.existing(42) + let values = ["a", "b", "c"] + let box = makeBox(values) + cache.setBox(box, forID: id, cost: cost(of: values)) + + #expect(cache.box(forID: id) === box) + } + + @Test("Count limit evicts oldest entries first (FIFO)") + func countLimitEvictsFIFO() { + let cache = RowDisplayCache(countLimit: 3, costLimit: 1_000_000) + for index in 1...3 { + cache.setBox(makeBox(["row\(index)"]), forID: .existing(index), cost: 4) + } + #expect(cache.box(forID: .existing(1)) != nil) + + // Fourth insertion should evict the first. + cache.setBox(makeBox(["row4"]), forID: .existing(4), cost: 4) + #expect(cache.box(forID: .existing(1)) == nil) + #expect(cache.box(forID: .existing(2)) != nil) + #expect(cache.box(forID: .existing(3)) != nil) + #expect(cache.box(forID: .existing(4)) != nil) + } + + @Test("Cost limit evicts even when count is under limit") + func costLimitEvicts() { + let cache = RowDisplayCache(countLimit: 1_000, costLimit: 10) + // First insert costs 6; under cap. + cache.setBox(makeBox(["abcdef"]), forID: .existing(1), cost: 6) + // Second insert costs 6 more; total 12 > 10, evicts first. + cache.setBox(makeBox(["123456"]), forID: .existing(2), cost: 6) + + #expect(cache.box(forID: .existing(1)) == nil) + #expect(cache.box(forID: .existing(2)) != nil) + } + + @Test("Replacing an existing key does not consume queue slot") + func replaceExistingKey() { + let cache = RowDisplayCache(countLimit: 2, costLimit: 1_000_000) + cache.setBox(makeBox(["v1"]), forID: .existing(1), cost: 2) + cache.setBox(makeBox(["v2"]), forID: .existing(2), cost: 2) + + // Replace id=1 without expanding the cache. + cache.setBox(makeBox(["v1-updated"]), forID: .existing(1), cost: 10) + #expect(cache.box(forID: .existing(1))?.values.first == "v1-updated") + #expect(cache.box(forID: .existing(2))?.values.first == "v2") + + // Adding a new entry now evicts the oldest in insertion order (still id=1 + // because replacing did not re-add it to the order). + cache.setBox(makeBox(["v3"]), forID: .existing(3), cost: 2) + #expect(cache.box(forID: .existing(1)) == nil) + #expect(cache.box(forID: .existing(2)) != nil) + #expect(cache.box(forID: .existing(3)) != nil) + } + + @Test("removeAll empties the cache and resets state") + func removeAllResetsState() { + let cache = RowDisplayCache() + for index in 1...10 { + cache.setBox(makeBox(["x"]), forID: .existing(index), cost: 1) + } + cache.removeAll() + for index in 1...10 { + #expect(cache.box(forID: .existing(index)) == nil) + } + + // Cache continues to work after removeAll. + cache.setBox(makeBox(["fresh"]), forID: .existing(100), cost: 5) + #expect(cache.box(forID: .existing(100))?.values.first == "fresh") + } + + @Test("Inserted row IDs of both kinds round-trip") + func mixedRowIDKinds() { + let cache = RowDisplayCache() + let existingID = RowID.existing(5) + let insertedID = RowID.inserted(UUID()) + cache.setBox(makeBox(["existing"]), forID: existingID, cost: 8) + cache.setBox(makeBox(["inserted"]), forID: insertedID, cost: 8) + #expect(cache.box(forID: existingID)?.values.first == "existing") + #expect(cache.box(forID: insertedID)?.values.first == "inserted") + } +} diff --git a/TableProTests/Plugins/MariaDBTypeNameTests.swift b/TableProTests/Plugins/MariaDBTypeNameTests.swift new file mode 100644 index 000000000..2fb80741a --- /dev/null +++ b/TableProTests/Plugins/MariaDBTypeNameTests.swift @@ -0,0 +1,120 @@ +// +// MariaDBTypeNameTests.swift +// TableProTests +// + +#if canImport(MySQLDriverPlugin) +import Testing + +@testable import MySQLDriverPlugin + +@Suite("MariaDB type name resolution") +struct MariaDBTypeNameTests { + private func resolve(typeRaw: UInt32, charsetnr: UInt32 = 33, flags: UInt = 0, length: UInt = 0) -> String { + mariaDBTypeName(typeRaw: typeRaw, flags: flags, charsetnr: charsetnr, length: length) + } + + private let binaryFlagAndCharset: (flags: UInt, charsetnr: UInt32) = (mysqlBinaryFlag, mysqlBinaryCharset) + + // MARK: - Numeric types (regression for #1209: numeric routed as bytes) + + @Test("INT family resolves to numeric type names") + func numericTypes() { + #expect(resolve(typeRaw: 1) == "TINYINT") + #expect(resolve(typeRaw: 2) == "SMALLINT") + #expect(resolve(typeRaw: 3) == "INT") + #expect(resolve(typeRaw: 8) == "BIGINT") + #expect(resolve(typeRaw: 9) == "MEDIUMINT") + } + + @Test("DECIMAL and floating point resolve to their type names") + func decimalAndFloat() { + #expect(resolve(typeRaw: 0) == "DECIMAL") + #expect(resolve(typeRaw: 4) == "FLOAT") + #expect(resolve(typeRaw: 5) == "DOUBLE") + #expect(resolve(typeRaw: 246) == "NEWDECIMAL") + } + + // MARK: - Temporal types + + @Test("Temporal types resolve to their type names") + func temporalTypes() { + #expect(resolve(typeRaw: 7) == "TIMESTAMP") + #expect(resolve(typeRaw: 10) == "DATE") + #expect(resolve(typeRaw: 11) == "TIME") + #expect(resolve(typeRaw: 12) == "DATETIME") + #expect(resolve(typeRaw: 13) == "YEAR") + #expect(resolve(typeRaw: 14) == "NEWDATE") + } + + // MARK: - Misc + + @Test("JSON, BIT, GEOMETRY resolve to their type names") + func miscTypes() { + #expect(resolve(typeRaw: 16) == "BIT") + #expect(resolve(typeRaw: 245) == "JSON") + #expect(resolve(typeRaw: 255) == "GEOMETRY") + } + + @Test("Unknown type code returns UNKNOWN") + func unknownType() { + #expect(resolve(typeRaw: 999) == "UNKNOWN") + } + + // MARK: - BINARY / VARBINARY (regression for #1217: data wipe on edit) + + @Test("BINARY(N) resolves to BINARY only when binary flag set") + func binaryWithFlagAndCharset() { + let (flags, charsetnr) = binaryFlagAndCharset + #expect(resolve(typeRaw: 254, charsetnr: charsetnr, flags: flags) == "BINARY") + } + + @Test("CHAR(N) without binary flag stays CHAR") + func charWithoutBinaryFlag() { + #expect(resolve(typeRaw: 254, charsetnr: 33, flags: 0) == "CHAR") + } + + @Test("CHAR with charset 63 but no binary flag stays CHAR") + func charBinaryCharsetWithoutFlag() { + #expect(resolve(typeRaw: 254, charsetnr: mysqlBinaryCharset, flags: 0) == "CHAR") + } + + @Test("CHAR with binary flag but non-binary charset stays CHAR") + func charFlagWithoutBinaryCharset() { + #expect(resolve(typeRaw: 254, charsetnr: 33, flags: mysqlBinaryFlag) == "CHAR") + } + + @Test("VARBINARY(N) resolves to VARBINARY only when binary flag set with charset 63") + func varbinaryWithFlagAndCharset() { + let (flags, charsetnr) = binaryFlagAndCharset + #expect(resolve(typeRaw: 253, charsetnr: charsetnr, flags: flags) == "VARBINARY") + } + + @Test("VARCHAR(N) without binary flag stays VARCHAR") + func varcharWithoutBinaryFlag() { + #expect(resolve(typeRaw: 253, charsetnr: 33, flags: 0) == "VARCHAR") + } + + // MARK: - BLOB family + + @Test("BLOB family resolves binary vs text by isBinary flag") + func blobFamilyBinaryVsText() { + let (flags, charsetnr) = binaryFlagAndCharset + #expect(resolve(typeRaw: 249, charsetnr: charsetnr, flags: flags) == "TINYBLOB") + #expect(resolve(typeRaw: 249, charsetnr: 33, flags: 0) == "TINYTEXT") + #expect(resolve(typeRaw: 250, charsetnr: charsetnr, flags: flags) == "MEDIUMBLOB") + #expect(resolve(typeRaw: 250, charsetnr: 33, flags: 0) == "MEDIUMTEXT") + #expect(resolve(typeRaw: 251, charsetnr: charsetnr, flags: flags) == "LONGBLOB") + #expect(resolve(typeRaw: 251, charsetnr: 33, flags: 0) == "LONGTEXT") + } + + @Test("Generic BLOB (252) routes by length and binary flag") + func blobLengthRouting() { + let (flags, charsetnr) = binaryFlagAndCharset + #expect(resolve(typeRaw: 252, charsetnr: charsetnr, flags: flags, length: 100) == "BLOB") + #expect(resolve(typeRaw: 252, charsetnr: charsetnr, flags: flags, length: 100_000) == "LONGBLOB") + #expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100) == "TEXT") + #expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100_000) == "LONGTEXT") + } +} +#endif diff --git a/TableProTests/Plugins/PluginCellValueSortKeyTests.swift b/TableProTests/Plugins/PluginCellValueSortKeyTests.swift index d07ff3b70..a3c3abb87 100644 --- a/TableProTests/Plugins/PluginCellValueSortKeyTests.swift +++ b/TableProTests/Plugins/PluginCellValueSortKeyTests.swift @@ -36,4 +36,38 @@ struct PluginCellValueSortKeyTests { #expect(b < c) #expect(a != b) } + + // MARK: - asText contract + // + // `asText` MUST return nil for `.bytes` so callers cannot accidentally treat + // binary cells as editable text. Returning empty string instead would cause + // the inline cell editor to display the empty field on click and commit "" + // on focus-out, silently wiping the original bytes (regression for #1217). + + @Test(".text.asText returns the text verbatim") + func textAsText() { + #expect(PluginCellValue.text("hello").asText == "hello") + #expect(PluginCellValue.text("").asText == "") + } + + @Test(".bytes.asText returns nil so inline edit is gated") + func bytesAsTextIsNil() { + #expect(PluginCellValue.bytes(Data([0xDE, 0xAD])).asText == nil) + #expect(PluginCellValue.bytes(Data()).asText == nil) + } + + @Test(".null.asText returns nil") + func nullAsText() { + #expect(PluginCellValue.null.asText == nil) + } + + // MARK: - asBytes contract + + @Test(".bytes.asBytes returns the data; other cases return nil") + func asBytes() { + let data = Data([0x01, 0x02, 0x03]) + #expect(PluginCellValue.bytes(data).asBytes == data) + #expect(PluginCellValue.text("hello").asBytes == nil) + #expect(PluginCellValue.null.asBytes == nil) + } }