mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Address PR feedback for utility docs and tests
This commit is contained in:
parent
f688e529f6
commit
545205bf6e
@ -9,6 +9,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
/// Returns a deterministic DJB2 hash for this string.
|
||||||
|
///
|
||||||
|
/// DJB2 is a non-cryptographic hash used for stable peer colors and
|
||||||
|
/// other UI-related deterministic values. Do not use it for cryptographic
|
||||||
|
/// or security-sensitive purposes.
|
||||||
func djb2() -> UInt64 {
|
func djb2() -> UInt64 {
|
||||||
var hash: UInt64 = 5381
|
var hash: UInt64 = 5381
|
||||||
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
|
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
|
||||||
|
|||||||
@ -9,15 +9,30 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
/// Split a nickname into base and a '#abcd' suffix if present
|
/// Splits a nickname into base and `#abcd` suffix when present.
|
||||||
|
///
|
||||||
|
/// Mention `@` characters are removed before parsing.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - `"alice#1a2b"` returns `("alice", "#1a2b")`
|
||||||
|
/// - `"@charlie#ffff"` returns `("charlie", "#ffff")`
|
||||||
|
/// - `"bob"` returns `("bob", "")`
|
||||||
|
/// - `"test##1234"` returns `("test##1234", "")`
|
||||||
func splitSuffix() -> (String, String) {
|
func splitSuffix() -> (String, String) {
|
||||||
let name = self.replacingOccurrences(of: "@", with: "")
|
let name = self.replacingOccurrences(of: "@", with: "")
|
||||||
guard name.count >= 5 else { return (name, "") }
|
guard name.count >= 5 else { return (name, "") }
|
||||||
let suffix = String(name.suffix(5))
|
let suffix = String(name.suffix(5))
|
||||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
let base = String(name.dropLast(5))
|
||||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
let hasValidSuffix = suffix.first == "#" && suffix.dropFirst().allSatisfy { character in
|
||||||
}) {
|
guard character.unicodeScalars.count == 1,
|
||||||
let base = String(name.dropLast(5))
|
let scalar = character.unicodeScalars.first else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (48...57).contains(scalar.value)
|
||||||
|
|| (65...70).contains(scalar.value)
|
||||||
|
|| (97...102).contains(scalar.value)
|
||||||
|
}
|
||||||
|
if hasValidSuffix, !base.contains("#") {
|
||||||
return (base, suffix)
|
return (base, suffix)
|
||||||
}
|
}
|
||||||
return (name, "")
|
return (name, "")
|
||||||
|
|||||||
99
bitchatTests/Utils/ColorPeerTests.swift
Normal file
99
bitchatTests/Utils/ColorPeerTests.swift
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// ColorPeerTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct ColorPeerTests {
|
||||||
|
|
||||||
|
@Test func peerColor_resolvesRepresentativeSeeds() {
|
||||||
|
let seeds = [
|
||||||
|
"alice",
|
||||||
|
"",
|
||||||
|
"caf\u{00E9}",
|
||||||
|
"a",
|
||||||
|
String(repeating: "long-seed-", count: 12),
|
||||||
|
"nostr:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
]
|
||||||
|
|
||||||
|
for seed in seeds {
|
||||||
|
#expect(isUsableRGBA(resolvedRGBA(for: Color(peerSeed: seed, isDark: false))),
|
||||||
|
"Light peer color should resolve for seed '\(seed)'")
|
||||||
|
#expect(isUsableRGBA(resolvedRGBA(for: Color(peerSeed: seed, isDark: true))),
|
||||||
|
"Dark peer color should resolve for seed '\(seed)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func peerColor_isDeterministicForSameSeedAndMode() {
|
||||||
|
let firstLight = resolvedRGBA(for: Color(peerSeed: "alice", isDark: false))
|
||||||
|
let secondLight = resolvedRGBA(for: Color(peerSeed: "alice", isDark: false))
|
||||||
|
let firstDark = resolvedRGBA(for: Color(peerSeed: "alice", isDark: true))
|
||||||
|
let secondDark = resolvedRGBA(for: Color(peerSeed: "alice", isDark: true))
|
||||||
|
|
||||||
|
#expect(firstLight == secondLight)
|
||||||
|
#expect(firstDark == secondDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func peerColor_supportsLightAndDarkModes() {
|
||||||
|
let light = resolvedRGBA(for: Color(peerSeed: "mode-check", isDark: false))
|
||||||
|
let dark = resolvedRGBA(for: Color(peerSeed: "mode-check", isDark: true))
|
||||||
|
|
||||||
|
#expect(isUsableRGBA(light))
|
||||||
|
#expect(isUsableRGBA(dark))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RGBA: Equatable {
|
||||||
|
let red: Double
|
||||||
|
let green: Double
|
||||||
|
let blue: Double
|
||||||
|
let alpha: Double
|
||||||
|
|
||||||
|
var values: [Double] {
|
||||||
|
[red, green, blue, alpha]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedRGBA(for color: Color) -> RGBA? {
|
||||||
|
#if os(iOS)
|
||||||
|
let platformColor = UIColor(color)
|
||||||
|
var red: CGFloat = 0
|
||||||
|
var green: CGFloat = 0
|
||||||
|
var blue: CGFloat = 0
|
||||||
|
var alpha: CGFloat = 0
|
||||||
|
guard platformColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return RGBA(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha))
|
||||||
|
#elseif os(macOS)
|
||||||
|
let platformColor = NSColor(color)
|
||||||
|
guard let rgbColor = platformColor.usingColorSpace(.deviceRGB) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var red: CGFloat = 0
|
||||||
|
var green: CGFloat = 0
|
||||||
|
var blue: CGFloat = 0
|
||||||
|
var alpha: CGFloat = 0
|
||||||
|
rgbColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||||
|
return RGBA(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha))
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isUsableRGBA(_ components: RGBA?) -> Bool {
|
||||||
|
guard let components else { return false }
|
||||||
|
return components.values.allSatisfy { (0.0...1.0).contains($0) }
|
||||||
|
}
|
||||||
87
bitchatTests/Utils/StringUtilsTests.swift
Normal file
87
bitchatTests/Utils/StringUtilsTests.swift
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// StringUtilsTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct StringUtilsTests {
|
||||||
|
|
||||||
|
// MARK: - DJB2
|
||||||
|
|
||||||
|
@Test func djb2_isDeterministic() {
|
||||||
|
let input = "alice"
|
||||||
|
|
||||||
|
#expect(input.djb2() == input.djb2())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func djb2_isCaseSensitive() {
|
||||||
|
let hashes = Set([
|
||||||
|
"Alice".djb2(),
|
||||||
|
"alice".djb2(),
|
||||||
|
"ALICE".djb2()
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(hashes.count > 1, "DJB2 should not collapse differently cased strings to one value")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func djb2_unicodeContentIsDeterministic() {
|
||||||
|
let unicodeName = "caf\u{00E9}"
|
||||||
|
|
||||||
|
#expect(unicodeName.djb2() == unicodeName.djb2())
|
||||||
|
#expect(unicodeName.djb2() != "cafe".djb2())
|
||||||
|
#expect("\u{1F44B}".djb2() != "\u{1F44B}!".djb2())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func djb2_emptyStringUsesSeedValue() {
|
||||||
|
#expect("".djb2() == 5381)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nickname suffixes
|
||||||
|
|
||||||
|
@Test func splitSuffix_parsesValidSuffix() {
|
||||||
|
let (base, suffix) = "alice#1a2b".splitSuffix()
|
||||||
|
|
||||||
|
#expect(base == "alice")
|
||||||
|
#expect(suffix == "#1a2b")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func splitSuffix_parsesMentionSuffix() {
|
||||||
|
let (base, suffix) = "@charlie#ffff".splitSuffix()
|
||||||
|
|
||||||
|
#expect(base == "charlie")
|
||||||
|
#expect(suffix == "#ffff")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func splitSuffix_returnsNameWhenNoSuffixExists() {
|
||||||
|
let (base, suffix) = "bob".splitSuffix()
|
||||||
|
|
||||||
|
#expect(base == "bob")
|
||||||
|
#expect(suffix == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func splitSuffix_rejectsInvalidHex() {
|
||||||
|
let (base, suffix) = "eve#xyz1".splitSuffix()
|
||||||
|
|
||||||
|
#expect(base == "eve#xyz1")
|
||||||
|
#expect(suffix == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func splitSuffix_rejectsShortSuffix() {
|
||||||
|
let (base, suffix) = "a#123".splitSuffix()
|
||||||
|
|
||||||
|
#expect(base == "a#123")
|
||||||
|
#expect(suffix == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func splitSuffix_rejectsDoubleHashSuffix() {
|
||||||
|
let (base, suffix) = "test##1234".splitSuffix()
|
||||||
|
|
||||||
|
#expect(base == "test##1234", "Double-hash nicknames must remain unsplit")
|
||||||
|
#expect(suffix == "", "Double-hash nicknames must not produce a suffix")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user