Stage 17: cap display-name special characters at 5 (ui + backend)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s

display_name validation gains a rule: at most 5 special characters — the '.' / '_'
punctuation (spaces, which separate words, don't count) — so a still-well-formed name
can't be mostly punctuation. Mirrored in the Go ValidateDisplayName and the UI
validDisplayName; both unit-tested (5 ok, 6 rejected, 'J. R. R. Tolkien' ok). Docs:
FUNCTIONAL (+ _ru).
This commit is contained in:
Ilia Denisov
2026-06-09 07:42:47 +02:00
parent 84ecc85f51
commit d87c0fb10b
6 changed files with 32 additions and 3 deletions
+14
View File
@@ -23,6 +23,11 @@ import (
// is unbounded; auto-provisioned platform names bypass this editor validation).
const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
// every name rune that is neither a letter nor a space) an editable display name may
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
@@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) {
if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
}
specials := 0
for _, r := range name {
if r != ' ' && !unicode.IsLetter(r) {
specials++
}
}
if specials > maxDisplayNameSpecials {
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
}
return name, nil
}
@@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) {
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
+2 -1
View File
@@ -141,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
+2 -1
View File
@@ -146,7 +146,8 @@ push доставляется через платформу.
### Профиль и настройки *(Stage 4 / 8)*
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
+3
View File
@@ -19,6 +19,9 @@ describe('validDisplayName', () => {
['Name2', false],
['', false],
['a'.repeat(33), false],
['a.a.a.a.a.a', true], // 5 dots — at the special-char limit
['a.a.a.a.a.a.a', false], // 6 dots — over the limit
['J. R. R. Tolkien', true], // 3 dots; spaces are not special
])('%s -> %s', (name, ok) => {
expect(validDisplayName(name)).toBe(ok);
});
+8 -1
View File
@@ -5,6 +5,10 @@
/** maxDisplayName caps the editable display name in runes. */
export const maxDisplayName = 32;
/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every
* rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */
export const maxDisplayNameSpecials = 5;
/** maxAwayMinutes bounds the daily away window's length (12 h). */
export const maxAwayMinutes = 12 * 60;
@@ -17,7 +21,10 @@ const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
/** displayNameError returns true when the trimmed name is a valid display name. */
export function validDisplayName(raw: string): boolean {
const name = raw.trim();
return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name);
const chars = [...name];
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length;
return specials <= maxDisplayNameSpecials;
}
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces