From 4b93e68bff29fb283c32b454b0177a625785337b Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sun, 2 Aug 2026 20:01:54 -0400 Subject: [PATCH] Support UTF-8 labels throughout. This implements the planned standardisation on UTF-8 labels for all strings (object labels, game titles, game descriptions). Encoding/decoding is now done consistently in `pygambit`. There was inconsistency in the wxWidgets encoding/decoding (between using the system conversion and UTF-8); this has been standardised. The temporary ASCII-only wxWidgets label editor has been removed. --- ChangeLog | 6 ++ doc/formats.rst | 2 +- src/games/file.cc | 35 ++++--- src/games/game.h | 199 ++++++++++++++++++++++++++++++++++---- src/gui/dleditmove.cc | 8 +- src/gui/dleditnode.cc | 5 +- src/gui/dlgameprop.cc | 9 +- src/gui/editlabel.cc | 64 +++++++----- src/gui/editlabel.h | 11 +-- src/gui/edittext.cc | 4 +- src/gui/efgdisplay.cc | 8 +- src/gui/efgpanel.cc | 24 ++--- src/gui/gamedoc.cc | 14 +-- src/gui/gameframe.cc | 5 +- src/gui/labelcell.cc | 40 +------- src/gui/labelcell.h | 6 +- src/gui/nfgpanel.cc | 11 +-- src/gui/nfgtable.cc | 22 ++--- src/gui/style.cc | 18 ++-- src/pygambit/action.pxi | 12 ++- src/pygambit/gambit.pxd | 4 +- src/pygambit/game.pxi | 28 ++++-- src/pygambit/infoset.pxi | 12 ++- src/pygambit/node.pxi | 14 +-- src/pygambit/outcome.pxi | 13 ++- src/pygambit/player.pxi | 12 ++- src/pygambit/strategy.pxi | 14 ++- tests/games.py | 25 +++-- tests/test_actions.py | 10 +- tests/test_extensive.py | 18 ++++ tests/test_file.py | 21 ++++ tests/test_infosets.py | 10 +- tests/test_node.py | 9 +- tests/test_outcomes.py | 10 +- tests/test_players.py | 10 +- 35 files changed, 461 insertions(+), 252 deletions(-) diff --git a/ChangeLog b/ChangeLog index 19e30ae56e..159d227b81 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,12 @@ ## [17.0.0] - unreleased ### Changed +- Game object labels may now be any well-formed UTF-8 text, not just printable ASCII; a label must + still not contain control characters, begin/end with whitespace, or contain two consecutive + whitespace characters -- where "whitespace" means any Unicode space separator (e.g. U+00A0 + NO-BREAK SPACE), not just the ASCII space. A game's title and description have no such + printable-character or spacing restriction, but must also be well-formed UTF-8. In `pygambit`, + labels/title/description are now encoded/decoded as UTF-8 rather than ASCII. (#862) - `lcp_solve` no longer silently absorbs internal exceptions if they occur, but instead these propagate out to match the behaviour of all other Nash equilibrium solvers. (#996) - `gnm_solve` executes callback when equilibrium is found rather than emitting all equilibria at the end of diff --git a/doc/formats.rst b/doc/formats.rst index 07667a5b81..9cbe6813d8 100644 --- a/doc/formats.rst +++ b/doc/formats.rst @@ -42,7 +42,7 @@ labels to objects if the game is going to be viewed in the graphical interface. In all cases, these labels are surrounded by the quotation character ("). The use of an explicit " character within a text label can be accomplished by preceding the embedded " characters with a -backwards slash (\). +backwards slash (\). Label text is encoded as UTF-8. This is an alternate version of the first line of the example file, in which the title of the game contains the term Bayesian game in quotation marks:: diff --git a/src/games/file.cc b/src/games/file.cc index db2adc1b5e..b3896e7b8a 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -20,6 +20,7 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // +#include #include #include #include @@ -34,6 +35,14 @@ namespace { using namespace Gambit; +// std::isspace/std::isdigit are undefined behavior when given a (possibly +// negative) plain `char` other than EOF; a UTF-8 continuation or lead byte +// has the high bit set and so is negative on a platform with signed char. +// These wrappers convert to `unsigned char` first, restricting the check to +// the ASCII whitespace/digit characters that terminate lexer tokens. +bool IsAsciiSpace(char c) { return std::isspace(static_cast(c)) != 0; } +bool IsAsciiDigit(char c) { return std::isdigit(static_cast(c)) != 0; } + using GameFileToken = enum { TOKEN_NUMBER = 0, TOKEN_TEXT = 1, @@ -121,7 +130,7 @@ GameFileToken GameFileLexer::GetNextToken() return (m_lastToken = TOKEN_EOF); } - while (isspace(c)) { + while (IsAsciiSpace(c)) { ReadChar(c); if (m_file.eof()) { return (m_lastToken = TOKEN_EOF); @@ -140,12 +149,12 @@ GameFileToken GameFileLexer::GetNextToken() else if (c == ',') { return (m_lastToken = TOKEN_COMMA); } - else if (isdigit(c) || c == '-' || c == '+') { + else if (IsAsciiDigit(c) || c == '-' || c == '+') { std::string buf; buf += c; ReadChar(c); - while (!m_file.eof() && isdigit(c)) { + while (!m_file.eof() && IsAsciiDigit(c)) { buf += c; ReadChar(c); } @@ -158,7 +167,7 @@ GameFileToken GameFileLexer::GetNextToken() if (c == '.') { buf += c; ReadChar(c); - while (!m_file.eof() && isdigit(c)) { + while (!m_file.eof() && IsAsciiDigit(c)) { buf += c; ReadChar(c); } @@ -166,12 +175,12 @@ GameFileToken GameFileLexer::GetNextToken() if (c == 'e' || c == 'E') { buf += c; ReadChar(c); - if (c != '+' && c != '-' && !isdigit(c)) { + if (c != '+' && c != '-' && !IsAsciiDigit(c)) { OnParseError("Invalid Token +/-"); } buf += c; ReadChar(c); - while (!m_file.eof() && isdigit(c)) { + while (!m_file.eof() && IsAsciiDigit(c)) { buf += c; ReadChar(c); } @@ -184,7 +193,7 @@ GameFileToken GameFileLexer::GetNextToken() else if (c == '/') { buf += c; ReadChar(c); - while (!m_file.eof() && isdigit(c)) { + while (!m_file.eof() && IsAsciiDigit(c)) { buf += c; ReadChar(c); } @@ -195,12 +204,12 @@ GameFileToken GameFileLexer::GetNextToken() else if (c == 'e' || c == 'E') { buf += c; ReadChar(c); - if (c != '+' && c != '-' && !isdigit(c)) { + if (c != '+' && c != '-' && !IsAsciiDigit(c)) { OnParseError("Invalid Token +/-"); } buf += c; ReadChar(c); - while (!m_file.eof() && isdigit(c)) { + while (!m_file.eof() && IsAsciiDigit(c)) { buf += c; ReadChar(c); } @@ -219,7 +228,7 @@ GameFileToken GameFileLexer::GetNextToken() buf += c; ReadChar(c); - while (!m_file.eof() && isdigit(c)) { + while (!m_file.eof() && IsAsciiDigit(c)) { buf += c; ReadChar(c); } @@ -241,7 +250,7 @@ GameFileToken GameFileLexer::GetNextToken() if (a == '\n') { IncreaseLine(); } - } while (!m_file.eof() && isspace(a)); + } while (!m_file.eof() && IsAsciiSpace(a)); if (a == '\"') { bool lastslash = false; @@ -276,14 +285,14 @@ GameFileToken GameFileLexer::GetNextToken() if (a == '\n') { IncreaseLine(); } - } while (!isspace(a)); + } while (!IsAsciiSpace(a)); } return (m_lastToken = TOKEN_TEXT); } m_lastText = ""; - while (!m_file.eof() && !isspace(c)) { + while (!m_file.eof() && !IsAsciiSpace(c)) { m_lastText += c; ReadChar(c); } diff --git a/src/games/game.h b/src/games/game.h index 13843209d6..4b470698b5 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -117,38 +117,169 @@ class InvalidFileException : public std::runtime_error { // Validation of labels //======================================================================= +/// @brief Decodes p_text as UTF-8, appending each decoded code point to p_codepoints. +/// +/// Validates well-formedness per the Unicode Standard's table of well-formed UTF-8 +/// byte sequences: rejects truncated sequences, overlong encodings, encoded +/// surrogate code points (U+D800-DFFF), and code points beyond U+10FFFF. +/// +/// @return true if p_text is well-formed UTF-8. On failure, p_codepoints holds +/// only the code points decoded before the invalid byte was reached, +/// and should not be used. +inline bool DecodeUtf8(const std::string &p_text, std::vector &p_codepoints) +{ + p_codepoints.clear(); + auto byte_at = [&p_text](size_t i) { return static_cast(p_text[i]); }; + size_t i = 0; + const size_t n = p_text.size(); + while (i < n) { + const unsigned char b0 = byte_at(i); + char32_t codepoint; + size_t length; + unsigned char lo1 = 0x80, hi1 = 0xbf; // valid range for the first continuation byte + if (b0 <= 0x7f) { + p_codepoints.push_back(b0); + ++i; + continue; + } + else if (b0 >= 0xc2 && b0 <= 0xdf) { + length = 2; + codepoint = b0 & 0x1f; + } + else if (b0 == 0xe0) { + length = 3; + codepoint = b0 & 0x0f; + lo1 = 0xa0; // excludes overlong 3-byte encodings + } + else if ((b0 >= 0xe1 && b0 <= 0xec) || (b0 >= 0xee && b0 <= 0xef)) { + length = 3; + codepoint = b0 & 0x0f; + } + else if (b0 == 0xed) { + length = 3; + codepoint = b0 & 0x0f; + hi1 = 0x9f; // excludes encoded surrogate code points U+D800-DFFF + } + else if (b0 == 0xf0) { + length = 4; + codepoint = b0 & 0x07; + lo1 = 0x90; // excludes overlong 4-byte encodings + } + else if (b0 >= 0xf1 && b0 <= 0xf3) { + length = 4; + codepoint = b0 & 0x07; + } + else if (b0 == 0xf4) { + length = 4; + codepoint = b0 & 0x07; + hi1 = 0x8f; // excludes code points beyond U+10FFFF + } + else { + return false; // stray continuation byte, 0xc0/0xc1, or 0xf5-0xff + } + if (i + length > n) { + return false; // truncated sequence + } + for (size_t k = 1; k < length; k++) { + const unsigned char b = byte_at(i + k); + const unsigned char lo = (k == 1) ? lo1 : 0x80; + const unsigned char hi = (k == 1) ? hi1 : 0xbf; + if (b < lo || b > hi) { + return false; + } + codepoint = (codepoint << 6) | (b & 0x3f); + } + p_codepoints.push_back(codepoint); + i += length; + } + return true; +} + +/// @brief Returns whether p_text is well-formed UTF-8. +/// @sa DecodeUtf8 +inline bool IsWellFormedUtf8(const std::string &p_text) +{ + std::vector codepoints; + return DecodeUtf8(p_text, codepoints); +} + +/// @brief Returns whether c is a Unicode space separator (category Zs). +/// +/// This is the complete, fixed set of "blank" characters that a label treats as +/// whitespace for the no-leading/trailing, single-whitespace-between-printables +/// rule -- the same treatment historically given only to the literal ASCII space. +/// The set has been stable across Unicode versions for a long time, so it is +/// hardcoded here rather than obtained from a full Unicode character database. +inline bool IsUnicodeSpaceSeparator(char32_t c) +{ + switch (c) { + case 0x0020: // SPACE + case 0x00a0: // NO-BREAK SPACE + case 0x1680: // OGHAM SPACE MARK + case 0x2000: // EN QUAD + case 0x2001: // EM QUAD + case 0x2002: // EN SPACE + case 0x2003: // EM SPACE + case 0x2004: // THREE-PER-EM SPACE + case 0x2005: // FOUR-PER-EM SPACE + case 0x2006: // SIX-PER-EM SPACE + case 0x2007: // FIGURE SPACE + case 0x2008: // PUNCTUATION SPACE + case 0x2009: // THIN SPACE + case 0x200a: // HAIR SPACE + case 0x202f: // NARROW NO-BREAK SPACE + case 0x205f: // MEDIUM MATHEMATICAL SPACE + case 0x3000: // IDEOGRAPHIC SPACE + return true; + default: + return false; + } +} + /// @brief Returns whether p_label is a valid label for a game object. /// /// A valid label either is the empty string (denoting the absence of a label), -/// or consists only of printable ASCII characters and spaces, begins and ends -/// with a printable character, and contains no two consecutive spaces. +/// or is well-formed UTF-8 containing no control characters (Unicode category Cc, +/// i.e. U+0000-001F, U+007F, and U+0080-009F, plus the line/paragraph separators +/// U+2028 and U+2029), begins and ends with a non-whitespace, non-control +/// character, and contains no two consecutive whitespace characters. "Whitespace" +/// here means any Unicode space separator (category Zs; see IsUnicodeSpaceSeparator), +/// not just the literal ASCII space -- so, for example, a label may not begin with +/// a no-break space (U+00A0) any more than it may begin with an ordinary space. /// -/// @note The set of valid labels is intended to be widened to permit Unicode in -/// a future version; this function is the single point at which the -/// definition is enforced. +/// @note Categories other than Cc/Zl/Zp/Zs (for example, format or private-use +/// characters) are deliberately not excluded: labels exist for object +/// identity and display, not as a place to police the full space of Unicode. inline bool IsValidLabel(const std::string &p_label) { if (p_label.empty()) { return true; } - auto is_printable = [](unsigned char c) { return c >= 0x21 && c <= 0x7e; }; - if (!is_printable(p_label.front()) || !is_printable(p_label.back())) { + std::vector codepoints; + if (!DecodeUtf8(p_label, codepoints)) { + return false; + } + auto is_control = [](char32_t c) { + return c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) || c == 0x2028 || c == 0x2029; + }; + auto is_whitespace = [](char32_t c) { return IsUnicodeSpaceSeparator(c); }; + auto is_printable = [&](char32_t c) { return !is_control(c) && !is_whitespace(c); }; + if (!is_printable(codepoints.front()) || !is_printable(codepoints.back())) { return false; } - bool previous_was_space = false; - for (const char ch : p_label) { - const auto c = static_cast(ch); - if (c == ' ') { - if (previous_was_space) { - return false; // two consecutive spaces + bool previous_was_whitespace = false; + for (const char32_t c : codepoints) { + if (is_whitespace(c)) { + if (previous_was_whitespace) { + return false; // two consecutive whitespace characters } - previous_was_space = true; + previous_was_whitespace = true; } else if (is_printable(c)) { - previous_was_space = false; + previous_was_whitespace = false; } else { - return false; // tab, newline, other control, or non-ASCII byte + return false; // control character } } return true; @@ -159,9 +290,27 @@ inline bool IsValidLabel(const std::string &p_label) inline void CheckLabel(const std::string &p_label) { if (!IsValidLabel(p_label)) { - throw ValueException("Invalid label: a label may contain only printable ASCII " - "characters and spaces, must not begin or end with a space, " - "and must not contain two consecutive spaces"); + throw ValueException("Invalid label: a label must be well-formed UTF-8 text " + "containing no control characters, must not begin or end " + "with whitespace, and must not contain two consecutive " + "whitespace characters"); + } +} + +/// @brief Returns whether p_text is valid free-form text (a game title or description). +/// +/// Unlike labels, free-form text has no semantic role in the model (it does not +/// identify an object or participate in any uniqueness constraint), so the only +/// requirement is that it be well-formed UTF-8. +/// @sa IsValidLabel +inline bool IsValidText(const std::string &p_text) { return IsWellFormedUtf8(p_text); } + +/// @brief Throws ValueException if p_text is not valid free-form text. +/// @sa IsValidText +inline void CheckText(const std::string &p_text) +{ + if (!IsValidText(p_text)) { + throw ValueException("Invalid text: must be well-formed UTF-8"); } } @@ -1010,12 +1159,20 @@ class GameRep : public std::enable_shared_from_this { /// Get the text label associated with the game virtual const std::string &GetTitle() const { return m_title; } /// Set the text label associated with the game - virtual void SetTitle(const std::string &p_title) { m_title = p_title; } + virtual void SetTitle(const std::string &p_title) + { + CheckText(p_title); + m_title = p_title; + } /// Get the text comment associated with the game virtual const std::string &GetDescription() const { return m_comment; } /// Set the text comment associated with the game - virtual void SetDescription(const std::string &p_comment) { m_comment = p_comment; } + virtual void SetDescription(const std::string &p_comment) + { + CheckText(p_comment); + m_comment = p_comment; + } /// Return the version number of the game. The version is incremented after each /// substantive change to the game (i.e. not merely involving labels) diff --git a/src/gui/dleditmove.cc b/src/gui/dleditmove.cc index 3b6d4c2f05..042ce8a4e9 100644 --- a/src/gui/dleditmove.cc +++ b/src/gui/dleditmove.cc @@ -84,9 +84,7 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) gridSizer->Add(new wxStaticText(this, wxID_STATIC, number), 0, wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT); - auto *name = - new LabelTextCtrl(this, wxID_ANY, wxString(action->GetLabel().c_str(), *wxConvCurrent), - LabelCharacterPolicy::AsciiOnly); + auto *name = new LabelTextCtrl(this, wxID_ANY, wxString::FromUTF8(action->GetLabel())); m_actionLabels.push_back(name); gridSizer->Add(name, 1, wxEXPAND); @@ -142,9 +140,7 @@ EditMoveDialog::EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset) auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); labelSizer->Add(new wxStaticText(this, wxID_STATIC, _("Information set label")), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5); - m_infosetLabel = - new LabelTextCtrl(this, wxID_ANY, wxString(p_infoset->GetLabel().c_str(), *wxConvCurrent), - LabelCharacterPolicy::AsciiOnly); + m_infosetLabel = new LabelTextCtrl(this, wxID_ANY, wxString::FromUTF8(p_infoset->GetLabel())); labelSizer->Add(m_infosetLabel, 1, wxALL | wxEXPAND, 5); topSizer->Add(labelSizer, 0, wxEXPAND); diff --git a/src/gui/dleditnode.cc b/src/gui/dleditnode.cc index d146c8e668..e631b5aeb6 100644 --- a/src/gui/dleditnode.cc +++ b/src/gui/dleditnode.cc @@ -40,8 +40,7 @@ EditNodeDialog::EditNodeDialog(wxWindow *p_parent, const GameNode &p_node) auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); labelSizer->Add(new wxStaticText(this, wxID_STATIC, _("Node label")), 0, wxALL | wxCENTER, 5); - m_nodeLabel = - new LabelTextCtrl(this, wxID_ANY, wxString(m_node->GetLabel().c_str(), *wxConvCurrent)); + m_nodeLabel = new LabelTextCtrl(this, wxID_ANY, wxString::FromUTF8(m_node->GetLabel())); labelSizer->Add(m_nodeLabel, 1, wxALL | wxCENTER | wxEXPAND, 5); topSizer->Add(labelSizer, 0, wxALL | wxEXPAND, 5); @@ -133,7 +132,7 @@ EditNodeDialog::EditNodeDialog(wxWindow *p_parent, const GameNode &p_node) item += ")"; } - m_outcome->Append(wxString(item.c_str(), *wxConvCurrent)); + m_outcome->Append(wxString::FromUTF8(item)); if (m_node->GetOutcome() == outcome) { m_outcome->SetSelection(outcome->GetNumber()); } diff --git a/src/gui/dlgameprop.cc b/src/gui/dlgameprop.cc index 8cca9d609b..0a4412f181 100644 --- a/src/gui/dlgameprop.cc +++ b/src/gui/dlgameprop.cc @@ -40,8 +40,7 @@ GamePropertiesDialog::GamePropertiesDialog(wxWindow *p_parent, GameDocument *p_d auto *titleSizer = new wxBoxSizer(wxHORIZONTAL); titleSizer->Add(new wxStaticText(this, wxID_STATIC, _("Title")), 0, wxALL | wxALIGN_CENTER, 5); - m_title = new wxTextCtrl(this, wxID_ANY, - wxString(m_doc->GetGame()->GetTitle().c_str(), *wxConvCurrent), + m_title = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(m_doc->GetGame()->GetTitle()), wxDefaultPosition, wxSize(400, -1)); titleSizer->Add(m_title, 1, wxALL | wxALIGN_CENTER, 5); @@ -50,9 +49,9 @@ GamePropertiesDialog::GamePropertiesDialog(wxWindow *p_parent, GameDocument *p_d auto *commentSizer = new wxBoxSizer(wxHORIZONTAL); commentSizer->Add(new wxStaticText(this, wxID_STATIC, _("Comment")), 0, wxALL | wxALIGN_CENTER, 5); - m_comment = new wxTextCtrl(this, wxID_ANY, - wxString(m_doc->GetGame()->GetDescription().c_str(), *wxConvCurrent), - wxDefaultPosition, wxSize(400, -1), wxTE_MULTILINE); + m_comment = + new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(m_doc->GetGame()->GetDescription()), + wxDefaultPosition, wxSize(400, -1), wxTE_MULTILINE); commentSizer->Add(m_comment, 1, wxALL | wxALIGN_CENTER, 5); topSizer->Add(commentSizer, 1, wxALL | wxEXPAND, 0); diff --git a/src/gui/editlabel.cc b/src/gui/editlabel.cc index 0bed480d52..e6ee01383e 100644 --- a/src/gui/editlabel.cc +++ b/src/gui/editlabel.cc @@ -31,34 +31,55 @@ namespace Gambit::GUI { -bool LabelTextCtrl::IsAsciiPrintable(wxUniChar p_char) +bool LabelTextCtrl::IsLabelWhitespace(wxUniChar p_char) { + // Tab/CR/LF/VT/FF are ASCII control characters, not Unicode space separators; + // they are treated as whitespace here (rather than rejected outright) purely as + // a live-typing convenience, so that e.g. pasting text containing a tab + // normalizes to a single space instead of being silently dropped. + if (p_char == '\t' || p_char == '\r' || p_char == '\n' || p_char == '\v' || p_char == '\f') { + return true; + } + // Unicode space separators (category Zs) -- normalized the same way as the + // literal ASCII space, matching Gambit::IsValidLabel in src/games/game.h. const auto value = static_cast(p_char); - return value >= 0x20 && value <= 0x7e; + switch (value) { + case 0x0020: // SPACE + case 0x00a0: // NO-BREAK SPACE + case 0x1680: // OGHAM SPACE MARK + case 0x2000: // EN QUAD + case 0x2001: // EM QUAD + case 0x2002: // EN SPACE + case 0x2003: // EM SPACE + case 0x2004: // THREE-PER-EM SPACE + case 0x2005: // FOUR-PER-EM SPACE + case 0x2006: // SIX-PER-EM SPACE + case 0x2007: // FIGURE SPACE + case 0x2008: // PUNCTUATION SPACE + case 0x2009: // THIN SPACE + case 0x200a: // HAIR SPACE + case 0x202f: // NARROW NO-BREAK SPACE + case 0x205f: // MEDIUM MATHEMATICAL SPACE + case 0x3000: // IDEOGRAPHIC SPACE + return true; + default: + return false; + } } -bool LabelTextCtrl::IsLabelWhitespace(wxUniChar p_char) +bool LabelTextCtrl::IsControlCharacter(wxUniChar p_char) { - return p_char == ' ' || p_char == '\t' || p_char == '\r' || p_char == '\n' || p_char == '\v' || - p_char == '\f'; + const auto value = static_cast(p_char); + return value <= 0x1f || value == 0x7f || (value >= 0x80 && value <= 0x9f) || value == 0x2028 || + value == 0x2029; } -bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy) +bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char) { - switch (p_policy) { - case LabelCharacterPolicy::AsciiOnly: - return IsAsciiPrintable(p_char) && !IsLabelWhitespace(p_char); - - case LabelCharacterPolicy::Unicode: - return !IsLabelWhitespace(p_char); - - default: - return false; - } + return !IsLabelWhitespace(p_char) && !IsControlCharacter(p_char); } -wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing, - LabelCharacterPolicy p_policy) +wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing) { wxString normalized; bool sawNonWhitespace = false; @@ -78,7 +99,7 @@ wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing, continue; } - if (!IsAllowedNonWhitespace(ch, p_policy)) { + if (!IsAllowedNonWhitespace(ch)) { continue; } @@ -127,9 +148,8 @@ void LabelTextCtrl::OnKillFocus(wxFocusEvent &p_event) } LabelTextCtrl::LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, - LabelCharacterPolicy p_policy, const wxPoint &p_pos, - const wxSize &p_size, long p_style) - : wxTextCtrl(p_parent, p_id, wxEmptyString, p_pos, p_size, p_style), m_policy(p_policy) + const wxPoint &p_pos, const wxSize &p_size, long p_style) + : wxTextCtrl(p_parent, p_id, wxEmptyString, p_pos, p_size, p_style) { ChangeValue(Normalize(p_value, true)); diff --git a/src/gui/editlabel.h b/src/gui/editlabel.h index a0034163a0..0b59176d50 100644 --- a/src/gui/editlabel.h +++ b/src/gui/editlabel.h @@ -30,15 +30,12 @@ namespace Gambit::GUI { -enum class LabelCharacterPolicy { AsciiOnly, Unicode }; - class LabelTextCtrl final : public wxTextCtrl { - LabelCharacterPolicy m_policy; bool m_normalizing{false}; - static bool IsAsciiPrintable(wxUniChar p_char); static bool IsLabelWhitespace(wxUniChar p_char); - static bool IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy); + static bool IsControlCharacter(wxUniChar p_char); + static bool IsAllowedNonWhitespace(wxUniChar p_char); wxString NormalizeValue(const wxString &p_value, bool p_stripTrailing) const; void NormalizeInPlace(bool p_stripTrailing); @@ -47,11 +44,9 @@ class LabelTextCtrl final : public wxTextCtrl { void OnKillFocus(wxFocusEvent &p_event); public: - static wxString Normalize(const wxString &p_value, bool p_stripTrailing, - LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly); + static wxString Normalize(const wxString &p_value, bool p_stripTrailing); LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, - LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly, const wxPoint &p_pos = wxDefaultPosition, const wxSize &p_size = wxDefaultSize, long p_style = 0); diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index ee5c2ba874..5cd35b19fd 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -64,8 +64,8 @@ EditableLabelText::EditableLabelText(wxWindow *p_parent, int p_id, const wxStrin Connect(m_staticText->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(EditableLabelText::OnClick)); - m_textCtrl = new LabelTextCtrl(this, wxID_ANY, p_value, LabelCharacterPolicy::AsciiOnly, - wxPoint(0, 0), p_size, wxTE_PROCESS_ENTER); + m_textCtrl = + new LabelTextCtrl(this, wxID_ANY, p_value, wxPoint(0, 0), p_size, wxTE_PROCESS_ENTER); Connect(m_textCtrl->GetId(), wxEVT_COMMAND_TEXT_ENTER, wxCommandEventHandler(EditableLabelText::OnAccept)); diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 4b3a9b5b92..f5de34a775 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -141,9 +141,9 @@ void OutcomeEditorPopup::BuildControls() for (size_t player = 1; player <= m_doc->GetGame()->NumPlayers(); ++player) { const GamePlayer gamePlayer = game->GetPlayer(player); - payoffSizer->Add(new wxStaticText(m_contentPanel, wxID_ANY, - wxString(gamePlayer->GetLabel().c_str(), *wxConvCurrent)), - 0, wxALIGN_CENTER_VERTICAL); + payoffSizer->Add( + new wxStaticText(m_contentPanel, wxID_ANY, wxString::FromUTF8(gamePlayer->GetLabel())), 0, + wxALIGN_CENTER_VERTICAL); auto *payoffCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); @@ -184,7 +184,7 @@ void OutcomeEditorPopup::LoadValues() return; } - m_labelCtrl->SetValue(wxString(outcome->GetLabel().c_str(), *wxConvCurrent)); + m_labelCtrl->SetValue(wxString::FromUTF8(outcome->GetLabel())); const Game game = m_doc->GetGame(); diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 73983fcd4d..8f4494d0d8 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -188,41 +188,39 @@ void TreePlayerPanel::OnUpdate() const wxColour color = m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player)); m_playerLabel->SetForegroundColour(color); - m_playerLabel->SetValue( - wxString(m_doc->GetGame()->GetPlayer(m_player)->GetLabel().c_str(), *wxConvCurrent)); + m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); m_payoff->SetForegroundColour(color); if (m_doc->GetWorkspace().GetCurrentProfile() > 0) { const std::string pay = m_doc->GetWorkspace().GetProfiles().GetPayoff(m_player); - m_payoff->SetLabel(wxT("Payoff: ") + wxString(pay.c_str(), *wxConvCurrent)); + m_payoff->SetLabel(wxT("Payoff: ") + wxString::FromUTF8(pay)); GetSizer()->Show(m_payoff, true); if (const GameNode node = m_doc->GetSelectNode()) { m_nodeValue->SetForegroundColour(color); std::string value = m_doc->GetWorkspace().GetProfiles().GetNodeValue(node, m_player); - m_nodeValue->SetLabel(wxT("Node value: ") + wxString(value.c_str(), *wxConvCurrent)); + m_nodeValue->SetLabel(wxT("Node value: ") + wxString::FromUTF8(value)); GetSizer()->Show(m_nodeValue, true); if (node->GetInfoset() && node->GetPlayer()->GetNumber() == m_player) { m_nodeProb->SetForegroundColour(color); value = m_doc->GetWorkspace().GetProfiles().GetRealizProb(node); - m_nodeProb->SetLabel(wxT("Node reached: ") + wxString(value.c_str(), *wxConvCurrent)); + m_nodeProb->SetLabel(wxT("Node reached: ") + wxString::FromUTF8(value)); GetSizer()->Show(m_nodeProb, true); m_infosetValue->SetForegroundColour(color); value = m_doc->GetWorkspace().GetProfiles().GetInfosetValue(node); - m_infosetValue->SetLabel(wxT("Infoset value: ") + wxString(value.c_str(), *wxConvCurrent)); + m_infosetValue->SetLabel(wxT("Infoset value: ") + wxString::FromUTF8(value)); GetSizer()->Show(m_infosetValue, true); m_infosetProb->SetForegroundColour(color); value = m_doc->GetWorkspace().GetProfiles().GetInfosetProb(node); - m_infosetProb->SetLabel(wxT("Infoset reached: ") + - wxString(value.c_str(), *wxConvCurrent)); + m_infosetProb->SetLabel(wxT("Infoset reached: ") + wxString::FromUTF8(value)); GetSizer()->Show(m_infosetProb, true); m_belief->SetForegroundColour(color); value = m_doc->GetWorkspace().GetProfiles().GetBeliefProb(node); - m_belief->SetLabel(wxT("Belief: ") + wxString(value.c_str(), *wxConvCurrent)); + m_belief->SetLabel(wxT("Belief: ") + wxString::FromUTF8(value)); GetSizer()->Show(m_belief, true); } else { @@ -285,8 +283,7 @@ void TreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) void TreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { - const wxString label = - LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + const wxString label = LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true); if (label.empty()) { wxBell(); @@ -314,8 +311,7 @@ void TreePlayerPanel::PostPendingChanges() } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); - m_playerLabel->SetValue( - wxString(m_doc->GetGame()->GetPlayer(m_player)->GetLabel().c_str(), *wxConvCurrent)); + m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); } } @@ -570,7 +566,7 @@ class gbtEfgPrintout : public wxPrintout { wxPrintout *EfgPanel::GetPrintout() { - return new gbtEfgPrintout(this, wxString(m_doc->GetGame()->GetTitle().c_str(), *wxConvCurrent)); + return new gbtEfgPrintout(this, wxString::FromUTF8(m_doc->GetGame()->GetTitle())); } bool EfgPanel::GetBitmap(wxBitmap &p_bitmap, int p_marginX, int p_marginY) diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index e396e5c718..5f058bd1b3 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -426,7 +426,7 @@ GamePlayer GameDocument::DoNewPlayer() void GameDocument::DoSetPlayerLabel(GamePlayer p_player, const wxString &p_label) { - p_player->SetLabel(p_label.ToStdString()); + p_player->SetLabel(p_label.ToStdString(wxConvUTF8)); NotifyChanged(GameModificationType::GameLabels); } @@ -452,19 +452,19 @@ void GameDocument::DoDeleteStrategy(GameStrategy p_strategy) void GameDocument::DoSetStrategyLabel(GameStrategy p_strategy, const wxString &p_label) { - p_strategy->SetLabel(p_label.ToStdString()); + p_strategy->SetLabel(p_label.ToStdString(wxConvUTF8)); NotifyChanged(GameModificationType::GameLabels); } void GameDocument::DoSetInfosetLabel(GameInfoset p_infoset, const wxString &p_label) { - p_infoset->SetLabel(p_label.ToStdString()); + p_infoset->SetLabel(p_label.ToStdString(wxConvUTF8)); NotifyChanged(GameModificationType::GameLabels); } void GameDocument::DoSetActionLabel(GameAction p_action, const wxString &p_label) { - p_action->SetLabel(p_label.ToStdString()); + p_action->SetLabel(p_label.ToStdString(wxConvUTF8)); NotifyChanged(GameModificationType::GameLabels); } @@ -504,7 +504,7 @@ void GameDocument::DoInsertAction(GameNode p_node) void GameDocument::DoSetNodeLabel(GameNode p_node, const wxString &p_label) { - p_node->SetLabel(p_label.ToStdString()); + p_node->SetLabel(p_label.ToStdString(wxConvUTF8)); NotifyChanged(GameModificationType::GameLabels); } @@ -632,7 +632,7 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la parsedPayoffs.push_back(lexical_cast(value.ToStdString())); } - const std::string label = p_label.ToStdString(); + const std::string label = p_label.ToStdString(wxConvUTF8); GameOutcome outcome = p_node->GetOutcome(); bool changed = !outcome; @@ -656,7 +656,7 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la } if (!outcome) { - outcome = m_game->NewOutcome(p_label.ToStdString()); + outcome = m_game->NewOutcome(p_label.ToStdString(wxConvUTF8)); m_game->SetOutcome(p_node, outcome); } else { diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index 413ae6a17c..2ee554c35e 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -310,11 +310,10 @@ void GameFrame::OnUpdate() gameTitle = m_doc->GetGame()->GetTitle(); if (!m_doc->GetFilename().empty()) { - SetTitle(wxT("Gambit - [") + m_doc->GetFilename() + wxT("] ") + - wxString(gameTitle.c_str(), *wxConvCurrent)); + SetTitle(wxT("Gambit - [") + m_doc->GetFilename() + wxT("] ") + wxString::FromUTF8(gameTitle)); } else { - SetTitle(wxT("Gambit - ") + wxString(gameTitle.c_str(), *wxConvCurrent)); + SetTitle(wxT("Gambit - ") + wxString::FromUTF8(gameTitle)); } if (m_doc->IsModified()) { diff --git a/src/gui/labelcell.cc b/src/gui/labelcell.cc index dad1846425..3c2820a80b 100644 --- a/src/gui/labelcell.cc +++ b/src/gui/labelcell.cc @@ -32,14 +32,11 @@ namespace Gambit::GUI { IMPLEMENT_DYNAMIC_CLASS(LabelEditorRefData, wxSheetCellTextEditorRefData) -LabelEditorRefData::LabelEditorRefData(LabelCharacterPolicy p_policy) : m_policy(p_policy) {} - void LabelEditorRefData::CreateEditor(wxWindow *parent, wxWindowID id, wxEvtHandler *evtHandler, wxSheet *sheet) { - auto *textCtrl = - new LabelTextCtrl(parent, id, wxEmptyString, m_policy, wxDefaultPosition, wxDefaultSize, - wxTE_PROCESS_TAB | wxTE_CENTER | wxBORDER_NONE); + auto *textCtrl = new LabelTextCtrl(parent, id, wxEmptyString, wxDefaultPosition, wxDefaultSize, + wxTE_PROCESS_TAB | wxTE_CENTER | wxBORDER_NONE); SetControl(textCtrl); textCtrl->Bind(wxEVT_KILL_FOCUS, [sheet](wxFocusEvent &event) { @@ -63,42 +60,9 @@ void LabelEditorRefData::CreateEditor(wxWindow *parent, wxWindowID id, wxEvtHand bool LabelEditorRefData::Copy(const LabelEditorRefData &p_other) { - m_policy = p_other.m_policy; return wxSheetCellTextEditorRefData::Copy(p_other); } -bool LabelEditorRefData::IsAcceptedKey(wxKeyEvent &p_event) -{ - if (!wxSheetCellEditorRefData::IsAcceptedKey(p_event)) { - return false; - } - - const int keycode = p_event.GetKeyCode(); - - // Let the editor start on ordinary printable ASCII characters. The - // LabelTextCtrl itself performs full normalization and filtering, so this - // does not need to duplicate the complete label policy. - if (m_policy == LabelCharacterPolicy::AsciiOnly) { - return keycode >= 0x20 && keycode <= 0x7e; - } - - // For the future Unicode policy, accept the key here and let LabelTextCtrl - // normalize/filter the resulting text. - return true; -} - -void LabelEditorRefData::StartingKey(wxKeyEvent &p_event) -{ - const int keycode = p_event.GetKeyCode(); - - if (m_policy == LabelCharacterPolicy::AsciiOnly && (keycode < 0x20 || keycode > 0x7e)) { - p_event.Skip(); - return; - } - - wxSheetCellTextEditorRefData::StartingKey(p_event); -} - bool LabelEditorRefData::EndEdit(const wxSheetCoords &p_coords, wxSheet *p_sheet) { auto *textCtrl = wxStaticCast(GetTextCtrl(), LabelTextCtrl); diff --git a/src/gui/labelcell.h b/src/gui/labelcell.h index 0b4c8f9951..ffb0cb9b50 100644 --- a/src/gui/labelcell.h +++ b/src/gui/labelcell.h @@ -31,16 +31,12 @@ namespace Gambit::GUI { class LabelEditorRefData final : public wxSheetCellTextEditorRefData { - LabelCharacterPolicy m_policy{LabelCharacterPolicy::AsciiOnly}; - public: - explicit LabelEditorRefData(LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly); + LabelEditorRefData() = default; void CreateEditor(wxWindow *, wxWindowID, wxEvtHandler *, wxSheet *) override; /// Override basic text editor behavior to normalize label editing. - bool IsAcceptedKey(wxKeyEvent &) override; - void StartingKey(wxKeyEvent &) override; bool EndEdit(const wxSheetCoords &, wxSheet *) override; bool Copy(const LabelEditorRefData &p_other); diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index 4e3846437c..f1c46da084 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -162,14 +162,13 @@ void TablePlayerPanel::OnUpdate() const wxColour color = m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player)); m_playerLabel->SetForegroundColour(color); - m_playerLabel->SetValue( - wxString(m_doc->GetGame()->GetPlayer(m_player)->GetLabel().c_str(), *wxConvCurrent)); + m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); if (m_doc->GetWorkspace().GetCurrentProfile() > 0) { m_payoff->SetForegroundColour(color); const std::string pay = m_doc->GetWorkspace().GetProfiles().GetPayoff(m_player); - m_payoff->SetLabel(wxT("Payoff: ") + wxString(pay.c_str(), *wxConvCurrent)); + m_payoff->SetLabel(wxT("Payoff: ") + wxString::FromUTF8(pay)); GetSizer()->Show(m_payoff, true); } else { @@ -222,8 +221,7 @@ void TablePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) void TablePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { - const wxString label = - LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + const wxString label = LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true); if (label.empty()) { wxBell(); @@ -251,8 +249,7 @@ void TablePlayerPanel::PostPendingChanges() } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); - m_playerLabel->SetValue( - wxString(m_doc->GetGame()->GetPlayer(m_player)->GetLabel().c_str(), *wxConvCurrent)); + m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); } } diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index 1bd8b5aa07..f777d35f56 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -237,12 +237,12 @@ wxString RowPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) const int player = m_table->GetRowHeaderPlayer(p_coords.GetCol()); const int strat = m_table->GetRowHeaderStrategy(p_coords.GetCol(), p_coords.GetRow()); - return {m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel().c_str(), *wxConvCurrent}; + return wxString::FromUTF8(m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel()); } void RowPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { - const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + const wxString label = LabelTextCtrl::Normalize(p_value, true); if (label.empty()) { wxBell(); @@ -250,8 +250,7 @@ void RowPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString } const wxString result = m_table->RenameRowHeaderStrategy( - p_coords.GetCol(), p_coords.GetRow(), - LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); + p_coords.GetCol(), p_coords.GetRow(), LabelTextCtrl::Normalize(p_value, true)); if (!result.empty()) { CallAfter([this, result] { ExceptionDialog(this, result.ToStdString()).ShowModal(); }); } @@ -266,7 +265,7 @@ wxSheetCellAttr RowPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetA if (m_table->GetRowHeaderColCount() > 0) { attr.SetForegroundColour( m_table->GetPlayerColor(m_table->GetRowHeaderPlayer(p_coords.GetCol()))); - attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData(LabelCharacterPolicy::AsciiOnly))); + attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData())); attr.SetReadOnly(m_table->IsReadOnly()); } else { @@ -546,12 +545,12 @@ wxString ColPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) const int player = m_table->GetColHeaderPlayer(p_coords.GetRow()); const int strat = m_table->GetColHeaderStrategy(p_coords.GetRow(), p_coords.GetCol()); - return {m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel().c_str(), *wxConvCurrent}; + return wxString::FromUTF8(m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel()); } void ColPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { - const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + const wxString label = LabelTextCtrl::Normalize(p_value, true); if (label.empty()) { wxBell(); @@ -559,8 +558,7 @@ void ColPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString } const wxString result = m_table->RenameColHeaderStrategy( - p_coords.GetCol(), p_coords.GetRow(), - LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); + p_coords.GetCol(), p_coords.GetRow(), LabelTextCtrl::Normalize(p_value, true)); if (!result.empty()) { CallAfter([this, result] { ExceptionDialog(this, result.ToStdString()).ShowModal(); }); } @@ -575,7 +573,7 @@ wxSheetCellAttr ColPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetA if (m_table->GetColHeaderRowCount() > 0) { attr.SetForegroundColour( m_table->GetPlayerColor(m_table->GetColHeaderPlayer(p_coords.GetRow()))); - attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData(LabelCharacterPolicy::AsciiOnly))); + attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData())); attr.SetReadOnly(m_table->IsReadOnly()); } else { @@ -798,7 +796,7 @@ wxString PayoffsWidget::GetCellValue(const wxSheetCoords &p_coords) const PureStrategyProfile profile = m_table->GetPayoffProfile(p_coords); auto player = m_table->GetPayoffPlayer(p_coords.GetCol()); - return {lexical_cast(profile->GetPayoff(player)).c_str(), *wxConvCurrent}; + return wxString::FromUTF8(lexical_cast(profile->GetPayoff(player))); } void PayoffsWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) @@ -1264,7 +1262,7 @@ class gbtNfgPrintout : public wxPrintout { wxPrintout *TableWidget::GetPrintout() { - return new gbtNfgPrintout(this, wxString(m_doc->GetGame()->GetTitle().c_str(), *wxConvCurrent)); + return new gbtNfgPrintout(this, wxString::FromUTF8(m_doc->GetGame()->GetTitle())); } bool TableWidget::GetBitmap(wxBitmap &p_bitmap, int p_marginX, int p_marginY) diff --git a/src/gui/style.cc b/src/gui/style.cc index 3d554c9263..867b165071 100644 --- a/src/gui/style.cc +++ b/src/gui/style.cc @@ -105,7 +105,7 @@ wxString EmptyLabel(const GameNode &, const AnalysisWorkspace &) { return {}; } wxString NodeLabelText(const GameNode &n, const AnalysisWorkspace &) { - return {n->GetLabel().c_str(), *wxConvCurrent}; + return wxString::FromUTF8(n->GetLabel()); } wxString PlayerLabel(const GameNode &n, const AnalysisWorkspace &) @@ -113,7 +113,7 @@ wxString PlayerLabel(const GameNode &n, const AnalysisWorkspace &) if (!n->GetPlayer()) { return {}; } - return {n->GetPlayer()->GetLabel().c_str(), *wxConvCurrent}; + return wxString::FromUTF8(n->GetPlayer()->GetLabel()); } wxString InfosetLabel(const GameNode &n, const AnalysisWorkspace &) @@ -121,7 +121,7 @@ wxString InfosetLabel(const GameNode &n, const AnalysisWorkspace &) if (!n->GetInfoset()) { return {}; } - return {n->GetInfoset()->GetLabel().c_str(), *wxConvCurrent}; + return wxString::FromUTF8(n->GetInfoset()->GetLabel()); } wxString InfosetIdLabel(const GameNode &n, const AnalysisWorkspace &) @@ -141,12 +141,12 @@ wxString InfosetIdLabel(const GameNode &n, const AnalysisWorkspace &) wxString RealizProbLabel(const GameNode &n, const AnalysisWorkspace &p_workspace) { - return {p_workspace.GetProfiles().GetRealizProb(n).c_str(), *wxConvCurrent}; + return wxString::FromUTF8(p_workspace.GetProfiles().GetRealizProb(n)); } wxString BeliefProbLabel(const GameNode &n, const AnalysisWorkspace &p_workspace) { - return {p_workspace.GetProfiles().GetBeliefProb(n).c_str(), *wxConvCurrent}; + return wxString::FromUTF8(p_workspace.GetProfiles().GetBeliefProb(n)); } wxString NodeValueLabel(const GameNode &n, const AnalysisWorkspace &p_workspace) @@ -171,7 +171,7 @@ wxString BranchLabelText(const GameNode &n, const AnalysisWorkspace &) { const GameNode parent = n->GetParent(); const int childNumber = n->GetPriorAction()->GetNumber(); - return {parent->GetInfoset()->GetAction(childNumber)->GetLabel().c_str(), *wxConvCurrent}; + return wxString::FromUTF8(parent->GetInfoset()->GetAction(childNumber)->GetLabel()); } wxString BranchProbLabel(const GameNode &n, const AnalysisWorkspace &p_workspace) @@ -187,7 +187,7 @@ wxString BranchProbLabel(const GameNode &n, const AnalysisWorkspace &p_workspace if (p_workspace.NumProfileLists() == 0) { return {}; } - return {p_workspace.GetProfiles().GetActionProb(parent, childNumber).c_str(), *wxConvCurrent}; + return wxString::FromUTF8(p_workspace.GetProfiles().GetActionProb(parent, childNumber)); } wxString BranchValueLabel(const GameNode &n, const AnalysisWorkspace &p_workspace) @@ -197,7 +197,7 @@ wxString BranchValueLabel(const GameNode &n, const AnalysisWorkspace &p_workspac } const GameNode parent = n->GetParent(); const int childNumber = n->GetPriorAction()->GetNumber(); - return {p_workspace.GetProfiles().GetActionValue(parent, childNumber).c_str(), *wxConvCurrent}; + return wxString::FromUTF8(p_workspace.GetProfiles().GetActionValue(parent, childNumber)); } using BranchLabelFunction = wxString (*)(const GameNode &, const AnalysisWorkspace &); @@ -285,7 +285,7 @@ void TreeRenderConfig::Load(const LegacyWorkspaceFile &p_workspace) if (p_workspace.font) { const auto &font = *p_workspace.font; SetFont(wxFont(font.size, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL, false, - wxString(font.face.c_str(), *wxConvCurrent))); + wxString::FromUTF8(font.face))); } const auto find = [](const std::string &value, const auto &names, auto fallback) { const auto iter = std::find(std::begin(names), std::end(names), value); diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi index 7964ff2a44..634f41022f 100644 --- a/src/pygambit/action.pxi +++ b/src/pygambit/action.pxi @@ -74,11 +74,13 @@ class Action: def label(self) -> str: """Get or set the text label of the action. - .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + .. versionchanged:: 17.0.0 + A label may now be any well-formed UTF-8 text, not just ASCII; it must still + contain no control characters, and must not begin/end with whitespace or have + two consecutive whitespace characters. "Whitespace" means any Unicode space + separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.action.deref().GetLabel().decode("ascii") + return self.action.deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: @@ -88,7 +90,7 @@ class Action: warnings.warn("In a future version, actions must have unique labels " "within their information set", FutureWarning) - self.action.deref().SetLabel(value.encode("ascii")) + self.action.deref().SetLabel(value.encode("utf-8")) @property def infoset(self) -> Infoset: diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 71538a7fcc..c1f4467fe0 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -310,10 +310,10 @@ cdef extern from "games/game.h": bool IsAgg() except + string GetTitle() except + - void SetTitle(string) except + + void SetTitle(string) except +ValueError string GetDescription() except + - void SetDescription(string) except + + void SetDescription(string) except +ValueError int NumPlayers() except + c_GamePlayer GetPlayer(int) except +IndexError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index f95d47abad..2aa7041b90 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -563,7 +563,7 @@ class Game: g = Game.wrap(NewTree()) g.title = title for player in (players or []): - g.game.deref().NewPlayer(str(player).encode("ascii")) + g.game.deref().NewPlayer(str(player).encode("utf-8")) return g @classmethod @@ -746,29 +746,37 @@ class Game: """Get or set the title of the game. The title of the game is an arbitrary string, generally intended - to be short. + to be short. Unlike object labels, a title has no printable-character or + spacing restriction; it need only be well-formed UTF-8 text. + + .. versionchanged:: 17.0.0 + Must be well-formed UTF-8 text; an invalid value now raises ``ValueError``. """ - return self.game.deref().GetTitle().decode("ascii") + return self.game.deref().GetTitle().decode("utf-8") @title.setter def title(self, value: str) -> None: - self.game.deref().SetTitle(value.encode("ascii")) + self.game.deref().SetTitle(value.encode("utf-8")) @property def description(self) -> str: """Get or set the description of the game. A game's description is an arbitrary string, and may be more discursive - than a title. + than a title. Unlike object labels, a description has no printable-character + or spacing restriction; it need only be well-formed UTF-8 text. .. versionchanged:: 16.6.0 Renamed ``Game.comment`` to ``Game.description``. + + .. versionchanged:: 17.0.0 + Must be well-formed UTF-8 text; an invalid value now raises ``ValueError``. """ - return self.game.deref().GetDescription().decode("ascii") + return self.game.deref().GetDescription().decode("utf-8") @description.setter def description(self, value: str) -> None: - self.game.deref().SetDescription(value.encode("ascii")) + self.game.deref().SetDescription(value.encode("utf-8")) @property def actions(self) -> GameActions: @@ -2088,7 +2096,7 @@ class Game: If `label` is empty, is already the label of another player, or (in an extensive game) is ``"Chance"``, the reserved label of the chance player. """ - return Player.wrap(self.game.deref().NewPlayer(label.encode("ascii"))) + return Player.wrap(self.game.deref().NewPlayer(label.encode("utf-8"))) def set_player(self, infoset: Infoset | str, player: Player | str) -> None: @@ -2144,7 +2152,7 @@ class Game: raise ValueError("add_outcome(): number of payoffs must equal number of players") else: payoffs = [0 for _ in self.players] - c = Outcome.wrap(self.game.deref().NewOutcome(label.encode("ascii"))) + c = Outcome.wrap(self.game.deref().NewOutcome(label.encode("utf-8"))) for player, payoff in zip(self.players, payoffs, strict=True): c[player] = payoff return c @@ -2231,7 +2239,7 @@ class Game: resolved_player = cython.cast(Player, self._resolve_player(player, "add_strategy")) return Strategy.wrap( - self.game.deref().NewStrategy(resolved_player.player, label.encode("ascii")) + self.game.deref().NewStrategy(resolved_player.player, label.encode("utf-8")) ) def delete_strategy(self, strategy: Strategy | str) -> None: diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 412cfac226..d9742eb319 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -166,15 +166,17 @@ class Infoset: def label(self) -> str: """Get or set the text label of the information set. - .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + .. versionchanged:: 17.0.0 + A label may now be any well-formed UTF-8 text, not just ASCII; it must still + contain no control characters, and must not begin/end with whitespace or have + two consecutive whitespace characters. "Whitespace" means any Unicode space + separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.infoset.deref().GetLabel().decode("ascii") + return self.infoset.deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: - self.infoset.deref().SetLabel(value.encode("ascii")) + self.infoset.deref().SetLabel(value.encode("utf-8")) @property def number(self) -> int: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index e1bdd30c14..cc625a7666 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -78,7 +78,7 @@ class NodeChildren: if self.parent.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL): raise KeyError(f"No action with label '{action}' at node") for act in self.parent.deref().GetInfoset().deref().GetActions(): - if act.deref().GetLabel().decode("ascii") == cython.cast(str, action): + if act.deref().GetLabel().decode("utf-8") == cython.cast(str, action): return Node.wrap(self.parent.deref().GetChild(act)) raise KeyError(f"No action with label '{action}' at node") if isinstance(action, Action): @@ -372,15 +372,17 @@ class Node: def label(self) -> str: """The text label associated with the node. - .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + .. versionchanged:: 17.0.0 + A label may now be any well-formed UTF-8 text, not just ASCII; it must still + contain no control characters, and must not begin/end with whitespace or have + two consecutive whitespace characters. "Whitespace" means any Unicode space + separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.node.deref().GetLabel().decode("ascii") + return self.node.deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: - self.node.deref().SetLabel(value.encode("ascii")) + self.node.deref().SetLabel(value.encode("utf-8")) @property def children(self) -> NodeChildren: diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 5c42cfd954..6d9e0c362b 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -66,14 +66,19 @@ class Outcome: .. versionchanged:: 16.7.0 An outcome label must be nonempty and unique within the game; an empty or duplicate - label now raises ``ValueError``. A label may contain only printable ASCII characters - and spaces, not begin/end with a space, nor have two consecutive spaces. + label now raises ``ValueError``. + + .. versionchanged:: 17.0.0 + A label may now be any well-formed UTF-8 text, not just ASCII; it must still + contain no control characters, and must not begin/end with whitespace or have + two consecutive whitespace characters. "Whitespace" means any Unicode space + separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.outcome.deref().GetLabel().decode("ascii") + return self.outcome.deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: - self.outcome.deref().SetLabel(value.encode("ascii")) + self.outcome.deref().SetLabel(value.encode("utf-8")) @property def number(self) -> int: diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 7cfc4f7de8..16abdd5279 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -248,15 +248,17 @@ class Player: def label(self) -> str: """Gets or sets the text label of the player. - .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + .. versionchanged:: 17.0.0 + A label may now be any well-formed UTF-8 text, not just ASCII; it must still + contain no control characters, and must not begin/end with whitespace or have + two consecutive whitespace characters. "Whitespace" means any Unicode space + separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.player.deref().GetLabel().decode("ascii") + return self.player.deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: - self.player.deref().SetLabel(value.encode("ascii")) + self.player.deref().SetLabel(value.encode("utf-8")) @property def number(self) -> int: diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 80a6054608..96516263ed 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -56,15 +56,19 @@ class Strategy: .. versionchanged:: 16.7.0 A strategy label must be nonempty and unique among the player's strategies; - an empty or duplicate label now raises ``ValueError``. A label may contain only - printable ASCII characters and spaces, not begin/end with a space, nor have two - consecutive spaces. + an empty or duplicate label now raises ``ValueError``. + + .. versionchanged:: 17.0.0 + A label may now be any well-formed UTF-8 text, not just ASCII; it must still + contain no control characters, and must not begin/end with whitespace or have + two consecutive whitespace characters. "Whitespace" means any Unicode space + separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.strategy.deref().GetLabel().decode("ascii") + return self.strategy.deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: - self.strategy.deref().SetLabel(value.encode("ascii")) + self.strategy.deref().SetLabel(value.encode("utf-8")) @property def game(self) -> Game: diff --git a/tests/games.py b/tests/games.py index 9e73e32d26..ec73c980ba 100644 --- a/tests/games.py +++ b/tests/games.py @@ -9,12 +9,25 @@ import pygambit as gbt # Label-validation fixtures. -# VALID: accepted by the C++ validator. -# INVALID: rejected by the validator -> ValueError (reach CheckLabel as ASCII bytes). -# NON_ASCII: rejected at the pygambit ASCII encode boundary -VALID_LABELS = ["x", "a b", "a b c"] -INVALID_LABELS = [" x", "x ", " ", "a b", "a\tb", "a\nb"] -NON_ASCII_LABELS = ["é", "naïve"] +# VALID: accepted by the C++ validator (IsValidLabel in src/games/game.h), including +# well-formed UTF-8 text (#862, 17.0.0). A single Unicode whitespace character +# (not just ASCII space) between two printables is valid, e.g. a no-break space. +# INVALID: rejected by the validator -> ValueError. Includes structural violations +# (leading/trailing/double whitespace) and control characters. "Whitespace" +# is generalized to any Unicode space separator (category Zs), not just the +# literal ASCII space -- so a no-break space (U+00A0) at the start/end, or +# doubled with an ordinary space, is invalid the same way a plain space is. +# Also includes control characters, both as literal ASCII bytes and as a +# control code point reached via a multi-byte UTF-8 encoding (U+0085 NEL, +# U+2028 LINE SEPARATOR). +# UNICODE_LABELS: non-ASCII labels, also included in VALID_LABELS; kept separate so +# tests can specifically exercise multi-byte UTF-8 decoding. +UNICODE_LABELS = ["é", "naïve", "日本語", "😀"] +VALID_LABELS = ["x", "a b", "a b", "a b c", *UNICODE_LABELS] +INVALID_LABELS = [ + " x", "x ", " ", "a b", "a\tb", "a\nb", "a\x01b", "a\x7fb", "a…b", + " x", "x ", " ", "a  b", "a
b", +] def read_from_file(fn: str) -> gbt.Game: diff --git a/tests/test_actions.py b/tests/test_actions.py index 28b61eeee5..3f4801e6c4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -33,13 +33,13 @@ def test_action_label_invalid_raises_valueerror(label: str): action.label = label -@pytest.mark.parametrize("label", games.NON_ASCII_LABELS) -def test_action_label_non_ascii_rejected(label: str): - """ASCII-only for 16.7 (#944); Unicode deferred to #862 (17.0).""" +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_action_label_unicode_accepted(label: str): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.create_stripped_down_poker_efg() action = next(iter(game.root.infoset.actions)) - with pytest.raises(UnicodeEncodeError): - action.label = label + action.label = label + assert action.label == label @pytest.mark.parametrize( diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 5f5e35e1f5..e8c7fa4ef0 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -33,6 +33,24 @@ def test_game_description(description: str): assert game.description == description +@pytest.mark.parametrize( + "text", + [ + " leading and trailing spaces, and double spaces ", + "a\ttab\nand\na newline", + "日本語 title with a trailing space ", + ], +) +def test_game_title_accepts_text_invalid_for_a_label(text: str): + """Title/description have no printable-character or spacing restriction (#862): + only well-formedness of the UTF-8 text is required, unlike object labels.""" + game = gbt.Game.new_tree() + game.title = text + game.description = text + assert game.title == text + assert game.description == text + + @pytest.mark.parametrize("players", [["Alice"], ["Oscar", "Felix"]]) def test_game_add_players_label(players: list): game = gbt.Game.new_tree() diff --git a/tests/test_file.py b/tests/test_file.py index ee7dade887..54dea7b17b 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -145,6 +145,27 @@ def test_nfg_outcomes_not_enough(): _parse_nfg(data) +def test_efg_label_malformed_utf8_rejected(): + """A genuinely ill-formed UTF-8 byte sequence in a label is rejected (#862). + + A Python ``str`` cannot hold ill-formed UTF-8, so this is not reachable through the + normal string-based read_efg/label-setter API; it is exercised here by feeding raw + bytes directly through a binary buffer, bypassing the encode step in read_game(). + """ + file_bytes = b'EFG 2 R "t" { "A\x80" "B" }\n""\np "" 1 1 "" { "l" "r" } 0\n' + with io.BytesIO(file_bytes) as f, pytest.raises(ValueError, match="Invalid label"): + gbt.read_efg(f) + + +def test_efg_title_malformed_utf8_rejected(): + """Malformed UTF-8 in the game title is rejected too, but via CheckText (#862): + the message is distinct from a label's, since title has no printable/spacing rule. + """ + file_bytes = b'EFG 2 R "t\x80" { "A" "B" }\n""\np "" 1 1 "" { "l" "r" } 0\n' + with io.BytesIO(file_bytes) as f, pytest.raises(ValueError, match="Invalid text"): + gbt.read_efg(f) + + def test_nfg_outcomes_too_many(): data = """ NFG 1 R "Two person 2 x 2 game with unique mixed equilibrium" { "Player 1" "Player 2" } diff --git a/tests/test_infosets.py b/tests/test_infosets.py index fb4ff58fef..65fc6361de 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -24,12 +24,12 @@ def test_infoset_label_invalid_raises_valueerror(label): game.root.infoset.label = label -@pytest.mark.parametrize("label", games.NON_ASCII_LABELS) -def test_infoset_label_non_ascii_rejected(label): - """ASCII-only for 16.7 (#944); Unicode deferred to #862 (17.0).""" +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_infoset_label_unicode_accepted(label): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(UnicodeEncodeError): - game.root.infoset.label = label + game.root.infoset.label = label + assert game.root.infoset.label == label def test_infoset_label_duplicate_within_player_raises_valueerror(): diff --git a/tests/test_node.py b/tests/test_node.py index 3b2ec5c613..b55cb7e65b 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1148,11 +1148,12 @@ def test_node_label_invalid_raises_valueerror(label): game.root.label = label -@pytest.mark.parametrize("label", games.NON_ASCII_LABELS) -def test_node_label_non_ascii_rejected(label): +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_node_label_unicode_accepted(label): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(UnicodeEncodeError): - game.root.label = label + game.root.label = label + assert game.root.label == label @pytest.mark.parametrize( diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 86a2737479..65e5f7fa51 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -39,13 +39,13 @@ def test_outcome_label_invalid_raises_valueerror(label: str): outcome.label = label -@pytest.mark.parametrize("label", games.NON_ASCII_LABELS) -def test_outcome_label_non_ascii_rejected(label: str): - """ASCII-only for 16.7 (#944); Unicode deferred to #862 (17.0).""" +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_outcome_label_unicode_accepted(label: str): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = gbt.Game.new_table([2, 2]) outcome = next(iter(game.outcomes)) - with pytest.raises(UnicodeEncodeError): - outcome.label = label + outcome.label = label + assert outcome.label == label @pytest.mark.parametrize( diff --git a/tests/test_players.py b/tests/test_players.py index 387baeb5ba..b5dd5c18e9 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -26,13 +26,13 @@ def test_player_label_invalid_raises_valueerror(label): player.label = label -@pytest.mark.parametrize("label", games.NON_ASCII_LABELS) -def test_player_label_non_ascii_rejected(label): - """ASCII-only for 16.7 (#944); Unicode deferred to #862 (17.0).""" +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_player_label_unicode_accepted(label): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = gbt.Game.new_table([2, 2]) player = next(iter(game.players)) - with pytest.raises(UnicodeEncodeError): - player.label = label + player.label = label + assert player.label == label def test_add_player_requires_label():