diff --git a/ShadowsocksX-NG2/App/Localizable.xcstrings b/ShadowsocksX-NG2/App/Localizable.xcstrings index 4e12d708..3d5ce78c 100644 --- a/ShadowsocksX-NG2/App/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Localizable.xcstrings @@ -12,6 +12,61 @@ } } }, + "名称最多 %lld 个字符": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name must be at most %lld characters" + } + } + } + }, + "密码最多 %lld 个字符": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password must be at most %lld characters" + } + } + } + }, + "插件参数最多 %lld 个字节": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin options must be at most %lld bytes" + } + } + } + }, + "服务器地址最多 %lld 个字符": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server address must be at most %lld characters" + } + } + } + }, + "端口必须是 1–65535 的数字(最多 %lld 位)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Port must be a number from 1 to 65535 (at most %lld digits)" + } + } + } + }, "请输入服务器名称": { "extractionState": "manual", "localizations": { diff --git a/ShadowsocksX-NG2/App/NewServerSheet.swift b/ShadowsocksX-NG2/App/NewServerSheet.swift index 5b911524..256fc4ec 100644 --- a/ShadowsocksX-NG2/App/NewServerSheet.swift +++ b/ShadowsocksX-NG2/App/NewServerSheet.swift @@ -16,7 +16,7 @@ struct NewServerSheet: View { /// 端口 8388、加密 aes-256-gcm、插件「无」。 @StateObject private var fields = ServerFormFields.newForm() @State private var isSubmitting = false - @FocusState private var nameFocused: Bool + @FocusState private var fieldFocus: ServerFormField? var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -25,7 +25,7 @@ struct NewServerSheet: View { ServerFormFieldsGrid( fields: fields, plugin: pluginSection, isEditable: !isSubmitting, - nameFocus: $nameFocused) + fieldFocus: $fieldFocus) HStack { Spacer() @@ -36,7 +36,7 @@ struct NewServerSheet: View { } .disabled(isSubmitting) .interactiveDismissDisabled(isSubmitting) - .defaultFocus($nameFocused, true) + .defaultFocus($fieldFocus, .name) .padding(20) .frame(minWidth: 520, minHeight: 400) } @@ -55,11 +55,10 @@ struct NewServerSheet: View { private func create() { guard !isSubmitting else { return } - guard fields.validateName() else { - nameFocused = true + guard fields.validateForSubmit(), let draft = fields.draft else { + fieldFocus = fields.firstErrorField return } - let draft = fields.draft isSubmitting = true Task { defer { isSubmitting = false } diff --git a/ShadowsocksX-NG2/App/ServerDetailView.swift b/ShadowsocksX-NG2/App/ServerDetailView.swift index 43d5a8c1..ee00e763 100644 --- a/ShadowsocksX-NG2/App/ServerDetailView.swift +++ b/ShadowsocksX-NG2/App/ServerDetailView.swift @@ -15,7 +15,7 @@ struct ServerDetailView: View { @StateObject private var fields = ServerFormFields() @State private var isSubmitting = false @State private var loadedServerID: NodeID? - @FocusState private var nameFocused: Bool + @FocusState private var fieldFocus: ServerFormField? // 分享/二维码状态由同 module 的 ServerDetailView+Share.swift 扩展驱动。 @State var showQR = false @State var qrImage: NSImage? @@ -110,7 +110,7 @@ struct ServerDetailView: View { } ServerFormFieldsGrid( fields: fields, plugin: formState?.plugin, isEditable: isEditable, - nameFocus: $nameFocused + fieldFocus: $fieldFocus ) .padding(.top, 20) @@ -178,12 +178,11 @@ struct ServerDetailView: View { private func save() { guard !isSubmitting, fields.hasChanges else { return } - guard fields.validateName() else { - nameFocused = true + guard fields.validateForSubmit(), let draft = fields.draft else { + fieldFocus = fields.firstErrorField return } let id = serverID - let draft = fields.draft isSubmitting = true Task { defer { isSubmitting = false } diff --git a/ShadowsocksX-NG2/App/ServerFormFields.swift b/ShadowsocksX-NG2/App/ServerFormFields.swift index 4fe4afcc..f6b8ab90 100644 --- a/ShadowsocksX-NG2/App/ServerFormFields.swift +++ b/ShadowsocksX-NG2/App/ServerFormFields.swift @@ -1,5 +1,29 @@ import SwiftUI +/// 表单字段标识:字段级校验错误的挂靠位与首错定位序(表单栅格顺序)。 +enum ServerFormField: Hashable { + case name + case address + case port + case password + case pluginOptions + + static let focusOrder: [ServerFormField] = [.name, .address, .port, .password, .pluginOptions] +} + +/// 字段级校验错误(issue #81):只携带字段、原因与上限,不回显字段内容; +/// 文案归呈现层(`ServerFormFieldsGrid`)。 +enum ServerFormFieldError: Equatable { + /// 名称必填(原有规则并入统一校验入口)。 + case missingName + /// 用户可见字符(Swift `Character`)超上限。 + case tooManyCharacters(limit: Int) + /// UTF-8 字节超上限(插件参数按最终字符串的字节数计量)。 + case tooManyBytes(limit: Int) + /// 端口草稿为空、含非十进制数字、超过五位或落在 1–65535 之外。 + case invalidPort +} + /// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/ /// ServerEditDraft 命令」的映射只在此写一次;新建与编辑两个表单面各自 /// `@StateObject` 持有一份,经 `load(from:)` 装载、`draft` 提交,第三个表单 @@ -7,39 +31,82 @@ import SwiftUI /// 与插件区 facts 由 caller 注入。 @MainActor final class ServerFormFields: ObservableObject { - @Published var address = "" - @Published var port = 8388 + /// 手动表单的产品输入上限(issue #81):不宣称是 DNS、Shadowsocks 或 + /// SIP003 协议的最大长度;导入、订阅刷新与既有保存数据不执行这些上限。 + static let nameCharacterLimit = 128 + static let addressCharacterLimit = 255 + static let portDigitLimit = 5 + static let passwordCharacterLimit = 1_024 + static let pluginOptionsUTF8Limit = 65_536 + + @Published var address = "" { didSet { clearError(.address) } } + /// 端口十进制编辑草稿:提交前经 `validateForSubmit()` 验证,不用数值绑定 + /// 以免转换失败时悄悄提交旧绑定值(issue #81)。 + @Published var portText = "" { didSet { clearError(.port) } } @Published var encryptionMethod = "" - @Published var password = "" + @Published var password = "" { didSet { clearError(.password) } } @Published var remark = "" { didSet { - if !nameIsEmpty { hasNameError = false } + if !nameIsEmpty { clearError(.name) } } } @Published var pluginChoice: PluginSelection = .none - @Published var pluginOptionsText = "" + @Published var pluginOptionsText = "" { didSet { clearError(.pluginOptions) } } @Published var showPassword = false - @Published private(set) var hasNameError = false + @Published private(set) var fieldErrors: [ServerFormField: ServerFormFieldError] = [:] @Published private var savedDraft: ServerEditDraft? + /// 变更检测按提交草稿比较;端口在草稿可解析时按数值比较(如前导零改写 + /// 不算变更),不可解析视为已变更,保证非法输入后保存入口仍可点击并在 + /// 提交时得到行内错误,而不是按钮死锁。 var hasChanges: Bool { - guard let savedDraft else { return false } - return draft != savedDraft + draft != savedDraft } private var nameIsEmpty: Bool { remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// 提交时才提示名称错误;后台仍独立执行完整校验。 - func validateName() -> Bool { - hasNameError = nameIsEmpty - return !hasNameError + /// 提交前的全量校验(issue #81):名称必填与五字段输入上限;与装载基线 + /// 逐字相同的超限字段放行(未修改的历史超长值不阻塞其他变更保存)。 + /// 错误逐字段发布,重新编辑对应字段即清除;首次装载不显示。 + @discardableResult + func validateForSubmit() -> Bool { + var errors: [ServerFormField: ServerFormFieldError] = [:] + if nameIsEmpty { + errors[.name] = .missingName + } else { + errors[.name] = characterLimitError( + raw: remark, measured: remark.trimmingCharacters(in: .whitespacesAndNewlines), + baseline: savedDraft?.remark, limit: Self.nameCharacterLimit) + } + errors[.address] = characterLimitError( + raw: address, measured: address.trimmingCharacters(in: .whitespaces), + baseline: savedDraft?.address, limit: Self.addressCharacterLimit) + if submittablePort == nil { + errors[.port] = .invalidPort + } + errors[.password] = characterLimitError( + raw: password, measured: password, + baseline: savedDraft?.password, limit: Self.passwordCharacterLimit) + if pluginChoice != .none { + errors[.pluginOptions] = utf8LimitError( + raw: pluginOptionsText, baseline: savedDraft?.pluginOptions, + limit: Self.pluginOptionsUTF8Limit) + } + fieldErrors = errors + return errors.isEmpty + } + + /// 首个待修正字段(首错定位用);无错误时为 nil。 + var firstErrorField: ServerFormField? { + ServerFormField.focusOrder.first { fieldErrors[$0] != nil } } /// 新建表单的默认字段:端口 8388、加密 aes-256-gcm、插件「无」。 static func newForm() -> ServerFormFields { let fields = ServerFormFields() + fields.portText = "8388" fields.encryptionMethod = "aes-256-gcm" return fields } @@ -58,20 +125,23 @@ final class ServerFormFields: ObservableObject { /// 明文显示。 func load(from state: ServerEditForm) { address = state.address - port = state.port + portText = String(state.port) encryptionMethod = state.encryptionMethod password = state.password remark = state.remark pluginChoice = state.plugin.selection pluginOptionsText = state.plugin.options showPassword = false - hasNameError = false + fieldErrors = [:] savedDraft = draft } - /// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。 - var draft: ServerEditDraft { - ServerEditDraft( + /// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。端口取自十进制 + /// 草稿;草稿不能作为提交端口时为 nil——本 getter 仅在 `validateForSubmit()` + /// 通过后消费,无效端口不得悄悄回落到旧值(issue #81)。 + var draft: ServerEditDraft? { + guard let port = submittablePort else { return nil } + return ServerEditDraft( address: address, port: port, encryptionMethod: encryptionMethod, @@ -80,38 +150,74 @@ final class ServerFormFields: ObservableObject { plugin: pluginChoice, pluginOptions: pluginOptionsText) } + + // MARK: - 校验实现 + + /// 端口草稿可否作为提交端口:非空、纯 ASCII 十进制数字、不超过五位且 + /// 落在 1–65535。 + private var submittablePort: Int? { + guard !portText.isEmpty, portText.count <= Self.portDigitLimit, + portText.allSatisfy { ("0"..."9").contains($0) }, + let port = Int(portText) + else { return nil } + return (1...65_535).contains(port) ? port : nil + } + + /// 字符上限:与基线逐字相同(未修改)的历史超长值放行;名称与地址按 + /// 提交值(现行首尾空白处理后)计量,密码不 trim、不 Unicode 归一化。 + private func characterLimitError( + raw: String, measured: String, baseline: String?, limit: Int + ) -> ServerFormFieldError? { + guard baseline == nil || raw != baseline else { return nil } + return measured.count > limit ? .tooManyCharacters(limit: limit) : nil + } + + /// 插件参数字节上限:按最终参数字符串的 UTF-8 字节数计量(含分隔符与 + /// 转义),与基线逐字相同的未修改值放行。 + private func utf8LimitError( + raw: String, baseline: String?, limit: Int + ) -> ServerFormFieldError? { + guard baseline == nil || raw != baseline else { return nil } + return raw.utf8.count > limit ? .tooManyBytes(limit: limit) : nil + } + + private func clearError(_ field: ServerFormField) { + guard fieldErrors[field] != nil else { return } + fieldErrors[field] = nil + } } -/// 新建与编辑共享的名称优先表单;焦点由提交入口持有,校验失败时定位名称。 +/// 新建与编辑共享的名称优先表单;焦点由提交入口持有,校验失败时定位首个 +/// 待修正字段。 struct ServerFormFieldsGrid: View { @ObservedObject var fields: ServerFormFields let plugin: PluginSectionState? let isEditable: Bool - let nameFocus: FocusState.Binding + let fieldFocus: FocusState.Binding var body: some View { VStack(alignment: .leading, spacing: 18) { column("名称") { TextField("名称", text: $fields.remark, prompt: Text("例如:香港服务器")) .textFieldStyle(.roundedBorder) - .focused(nameFocus) + .focused(fieldFocus, equals: .name) .disabled(!isEditable) - if fields.hasNameError { - Text("请输入服务器名称") - .font(.footnote) - .foregroundStyle(.red) - } + fieldErrorLabel(.name) } ServerEndpointLayout { column("服务器地址") { TextField("服务器地址", text: $fields.address) .textFieldStyle(.roundedBorder) + .focused(fieldFocus, equals: .address) .disabled(!isEditable) + fieldErrorLabel(.address) } column("端口") { - TextField("端口", value: $fields.port, format: .number.grouping(.never)) + TextField("端口", text: $fields.portText) .textFieldStyle(.roundedBorder) + .focused(fieldFocus, equals: .port) .disabled(!isEditable) + fieldErrorLabel(.port) } } column("加密方式") { @@ -134,6 +240,7 @@ struct ServerFormFieldsGrid: View { .textFieldStyle(.roundedBorder) .textContentType(nil) .autocorrectionDisabled() + .focused(fieldFocus, equals: .password) .disabled(!isEditable) Button { fields.showPassword.toggle() @@ -144,13 +251,16 @@ struct ServerFormFieldsGrid: View { .help(fields.showPassword ? "隐藏密码" : "显示密码") .accessibilityLabel(fields.showPassword ? "隐藏密码" : "显示密码") } + fieldErrorLabel(.password) } column("插件") { ServerPluginSection( selection: $fields.pluginChoice, optionsText: $fields.pluginOptionsText, plugin: plugin, - isEditable: isEditable) + isEditable: isEditable, + optionsError: fields.fieldErrors[.pluginOptions], + optionsFocus: fieldFocus) } } } @@ -166,6 +276,36 @@ struct ServerFormFieldsGrid: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + /// 字段行内错误:只呈现字段、原因与上限,不回显字段内容(issue #81)。 + @ViewBuilder + private func fieldErrorLabel(_ field: ServerFormField) -> some View { + if let error = fields.fieldErrors[field] { + Text(errorText(error, field: field)) + .font(.footnote) + .foregroundStyle(.red) + } + } + + private func errorText(_ error: ServerFormFieldError, field: ServerFormField) + -> LocalizedStringKey + { + switch error { + case .missingName: + return "请输入服务器名称" + case .tooManyCharacters(let limit): + switch field { + case .name: return "名称最多 \(limit) 个字符" + case .address: return "服务器地址最多 \(limit) 个字符" + case .password: return "密码最多 \(limit) 个字符" + case .port, .pluginOptions: return "" + } + case .tooManyBytes(let limit): + return "插件参数最多 \(limit) 个字节" + case .invalidPort: + return "端口必须是 1–65535 的数字(最多 \(ServerFormFields.portDigitLimit) 位)" + } + } } /// 扣除间距后按 4:1 分配地址和端口宽度,高度由字段自身决定。 diff --git a/ShadowsocksX-NG2/App/ServerPluginSection.swift b/ShadowsocksX-NG2/App/ServerPluginSection.swift index 70ddb3ba..d1d09305 100644 --- a/ShadowsocksX-NG2/App/ServerPluginSection.swift +++ b/ShadowsocksX-NG2/App/ServerPluginSection.swift @@ -8,6 +8,9 @@ struct ServerPluginSection: View { @Binding var optionsText: String let plugin: PluginSectionState? let isEditable: Bool + /// 参数字段的行内校验错误(issue #81,如字节超限)。 + let optionsError: ServerFormFieldError? + let optionsFocus: FocusState.Binding var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -72,7 +75,13 @@ struct ServerPluginSection: View { prompt: Text("选择插件后填写参数(如 mode=websocket;host=…)") ) .textFieldStyle(.roundedBorder) + .focused(optionsFocus, equals: .pluginOptions) .disabled(!isEditable) + if let optionsError { + Text(optionsErrorText(optionsError)) + .font(.footnote) + .foregroundStyle(.red) + } } } } @@ -94,4 +103,12 @@ struct ServerPluginSection: View { } } } + + /// 参数错误文案:只呈现原因与上限,不回显参数内容。 + private func optionsErrorText(_ error: ServerFormFieldError) -> LocalizedStringKey { + switch error { + case .tooManyBytes(let limit): return "插件参数最多 \(limit) 个字节" + case .missingName, .tooManyCharacters, .invalidPort: return "" + } + } } diff --git a/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift b/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift new file mode 100644 index 00000000..3a25021b --- /dev/null +++ b/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift @@ -0,0 +1,230 @@ +import Testing + +@testable import ShadowsocksX_NG2 + +/// 服务器表单的手动输入上限与端口十进制草稿(issue #81):全部经表单草稿 +/// 模块的公开面观察(装载、校验、提交草稿、变更检测),不触碰内部实现。 +@MainActor +struct ServerFormFieldLimitsTests { + private static let overLongName = String( + repeating: "港", count: ServerFormFields.nameCharacterLimit + 1) + private static let overLongAddress = String( + repeating: "a", count: ServerFormFields.addressCharacterLimit + 1) + private static let overLongPassword = String( + repeating: "p", count: ServerFormFields.passwordCharacterLimit + 1) + private static let overLongOptions = String( + repeating: "港", count: ServerFormFields.pluginOptionsUTF8Limit / 3 + 1) + + private static func editForm( + name: String = "服务器", + address: String = "203.0.113.7", + port: Int = 8388, + password: String = "password", + pluginOptions: String = "" + ) -> ServerEditForm { + ServerEditForm( + address: address, + port: port, + encryptionMethod: "aes-256-gcm", + password: password, + remark: name, + plugin: PluginSectionState( + selection: .managed(program: "v2ray-plugin"), + managed: ManagedPluginCatalog.plugins, + provided: true, + optionsPresent: !pluginOptions.isEmpty, + options: pluginOptions), + isEditable: true) + } + + private static func loadedFields(_ form: ServerEditForm = editForm()) -> ServerFormFields { + let fields = ServerFormFields() + fields.load(from: form) + return fields + } + + // MARK: - 端口十进制草稿 + + @Test( + arguments: [ + ("", "空输入"), ("abc", "非数字"), ("12a4", "中途字母"), ("123456", "六位数字"), + ("0", "零"), ("65536", "超范围"), ("-1", "负号"), (" 8388", "带空格"), + ("8388", "全角数字"), + ]) + func invalidPortDraftsBlockSubmissionAndNeverSubmitStaleValues(_ text: String, _ label: String) { + let fields = Self.loadedFields() + fields.portText = text + #expect(!fields.validateForSubmit(), "\(label)") + #expect(fields.fieldErrors[.port] == .invalidPort, "\(label)") + #expect(fields.draft == nil, "无效端口不得回落到旧绑定值:\(label)") + #expect(fields.hasChanges, "非法端口仍是变更,保存入口不得死锁:\(label)") + } + + @Test(arguments: [("1", 1), ("65535", 65_535), ("8388", 8388), ("08388", 8388)]) + func validPortDraftsSubmitParsedValues(_ text: String, expected: Int) { + let fields = Self.loadedFields() + fields.portText = text + #expect(fields.validateForSubmit()) + #expect(fields.draft?.port == expected) + } + + @Test + func numericallyEqualPortRewriteIsNotAChange() { + let fields = Self.loadedFields() + fields.portText = "08388" + #expect(!fields.hasChanges) + fields.portText = "8389" + #expect(fields.hasChanges) + } + + // MARK: - 字符上限(Swift Character 计量) + + @Test + func nameLimitCountsCharactersIncludingComposedSequences() { + let fields = Self.loadedFields() + fields.remark = String(repeating: "港", count: ServerFormFields.nameCharacterLimit) + #expect(fields.validateForSubmit()) + fields.remark = Self.overLongName + #expect(!fields.validateForSubmit()) + #expect( + fields.fieldErrors[.name] == .tooManyCharacters(limit: ServerFormFields.nameCharacterLimit)) + + // 组合字符序列按用户可见字符计数:e + U+0301 是一个 Character。 + fields.remark = String(repeating: "e\u{301}", count: ServerFormFields.nameCharacterLimit) + #expect(fields.validateForSubmit()) + fields.remark = String(repeating: "e\u{301}", count: ServerFormFields.nameCharacterLimit + 1) + #expect(!fields.validateForSubmit()) + } + + @Test + func nameAndAddressLimitsMeasurePreparedValuesAfterExistingTrimming() { + let fields = Self.loadedFields() + // 名称/地址沿用首尾空白处理后计量:首尾空白不计入上限。 + fields.remark = " " + String(repeating: "港", count: ServerFormFields.nameCharacterLimit) + " " + #expect(fields.validateForSubmit()) + fields.address = " " + String(repeating: "a", count: ServerFormFields.addressCharacterLimit) + #expect(fields.validateForSubmit()) + // 去掉空白后仍超一位才越界。 + fields.remark = " " + Self.overLongName + #expect(!fields.validateForSubmit()) + fields.remark = "服务器" + fields.address = Self.overLongAddress + #expect(!fields.validateForSubmit()) + #expect( + fields.fieldErrors[.address] + == .tooManyCharacters(limit: ServerFormFields.addressCharacterLimit)) + } + + @Test + func passwordLimitDoesNotTrimOrNormalize() { + let fields = Self.loadedFields() + fields.password = String(repeating: "p", count: ServerFormFields.passwordCharacterLimit) + #expect(fields.validateForSubmit(), "恰好达标") + // 密码不 trim、不归一化:尾随空格计入长度,1025 位即越界。 + fields.password = String(repeating: "p", count: ServerFormFields.passwordCharacterLimit) + " " + #expect(!fields.validateForSubmit()) + fields.password = Self.overLongPassword + #expect(!fields.validateForSubmit()) + #expect( + fields.fieldErrors[.password] + == .tooManyCharacters(limit: ServerFormFields.passwordCharacterLimit)) + } + + // MARK: - 插件参数字节上限(UTF-8 计量) + + @Test + func pluginOptionsLimitCountsUTF8BytesOfTheWholeString() { + let fields = Self.loadedFields() + fields.pluginOptionsText = String( + repeating: "a", count: ServerFormFields.pluginOptionsUTF8Limit) + #expect(fields.validateForSubmit(), "恰好 65,536 字节达标") + fields.pluginOptionsText = String( + repeating: "a", count: ServerFormFields.pluginOptionsUTF8Limit + 1) + #expect(!fields.validateForSubmit()) + #expect( + fields.fieldErrors[.pluginOptions] + == .tooManyBytes(limit: ServerFormFields.pluginOptionsUTF8Limit)) + + // 多字节字符按字节计:21846 个「港」= 65,538 字节越界。 + fields.pluginOptionsText = String(repeating: "港", count: 21_846) + #expect(!fields.validateForSubmit()) + } + + @Test + func hiddenOptionsDraftDoesNotBlockSaveWhenPluginIsNone() { + let fields = Self.loadedFields() + fields.pluginChoice = .none + fields.pluginOptionsText = Self.overLongOptions + #expect(fields.validateForSubmit(), "插件「无」时隐藏参数草稿不阻塞保存") + } + + // MARK: - 超长基线兼容 + + @Test + func unmodifiedOverLongBaselineFieldsDoNotBlockSavingOtherChanges() { + let fields = Self.loadedFields( + Self.editForm( + name: Self.overLongName, address: Self.overLongAddress, + password: Self.overLongPassword, pluginOptions: Self.overLongOptions)) + #expect(fields.validateForSubmit(), "未修改的历史超长字段放行") + #expect(fields.draft != nil) + + // 只改名称(合规新值),其余超长基线字段继续放行。 + fields.remark = "新名称" + #expect(fields.validateForSubmit()) + + // 主动修改超限字段后必须满足上限。 + fields.address = Self.overLongAddress + "x" + #expect(!fields.validateForSubmit()) + #expect( + fields.fieldErrors[.address] + == .tooManyCharacters(limit: ServerFormFields.addressCharacterLimit)) + } + + @Test + func baselineExemptionUsesTheLoadedSnapshotNotLiveCatalogValues() { + let fields = Self.loadedFields(Self.editForm(name: Self.overLongName)) + #expect(fields.validateForSubmit()) + // 再次成功装载后基线刷新。 + fields.remark = "正常名称" + fields.load(from: Self.editForm(name: Self.overLongName)) + #expect(fields.validateForSubmit(), "重载后基线恢复为超长保存值,未修改即放行") + } + + // MARK: - 错误呈现时机 + + @Test + func firstOpenShowsNoErrorsAndErrorsClearOnFieldEdit() { + let fields = ServerFormFields.newForm() + #expect(fields.fieldErrors.isEmpty, "首次打开不显示校验错误") + #expect(!fields.validateForSubmit(), "新建面名称为空仍拒绝提交(原有必填规则)") + #expect(fields.firstErrorField == .name) + #expect(fields.fieldErrors[.name] == .missingName) + + fields.remark = "有名称" + #expect(fields.fieldErrors[.name] == nil, "重新编辑对应字段即清除") + + fields.portText = "abc" + #expect(!fields.validateForSubmit()) + fields.portText = "8388" + #expect(fields.fieldErrors[.port] == nil) + } + + @Test + func firstErrorFieldFollowsGridOrder() { + let fields = Self.loadedFields() + fields.portText = "0" + fields.remark = "" + fields.password = Self.overLongPassword + #expect(!fields.validateForSubmit()) + #expect(fields.firstErrorField == .name, "名称 → 地址 → 端口 → 密码 → 参数") + } + + @Test + func loadedEditFormShowsNoErrorsUntilValidated() { + let fields = Self.loadedFields(Self.editForm(name: Self.overLongName)) + #expect(fields.fieldErrors.isEmpty) + #expect(fields.validateForSubmit()) + #expect(fields.fieldErrors.isEmpty) + } +}