Merge branch 'main' of github.com:evennia/evennia

This commit is contained in:
Griatch 2025-11-19 18:45:07 +01:00
commit 20dcb19776
9 changed files with 286 additions and 32 deletions

View file

@ -61,10 +61,10 @@ This upgrade requires running `evennia migrate` on your existing database
- Feat (backwards incompatible): RUN MIGRATIONS (`evennia migrate`): Now requiring Django 5.1 (Griatch) - Feat (backwards incompatible): RUN MIGRATIONS (`evennia migrate`): Now requiring Django 5.1 (Griatch)
- Feat (backwards incompatible): Drop support and testing for Python 3.10 (Griatch) - Feat (backwards incompatible): Drop support and testing for Python 3.10 (Griatch)
- [Feat][pull3719]: Support Python 3.13. (0xDEADFED5) - [Feat][pull3719]: Support Python 3.13. (electroglyph)
- [Feat][pull3633]: Default object's default descs are now taken from a `default_description` - [Feat][pull3633]: Default object's default descs are now taken from a `default_description`
class variable instead of the `desc` Attribute always being set (count-infinity) class variable instead of the `desc` Attribute always being set (count-infinity)
- [Feat][pull3718]: Remove twistd.bat creation for Windows, should not be needed anymore (0xDEADFED5) - [Feat][pull3718]: Remove twistd.bat creation for Windows, should not be needed anymore (electroglyph)
- [Feat][pull3756]: Updated German translation (JohnFi) - [Feat][pull3756]: Updated German translation (JohnFi)
- [Feat][pull3757]: Add more i18n strings to `DefaultObject` for easier translation (JohnFi) - [Feat][pull3757]: Add more i18n strings to `DefaultObject` for easier translation (JohnFi)
- [Feat][pull3783]: Support users of `ruff` linter by adding compatible config in `pyproject.toml` (jaborsh) - [Feat][pull3783]: Support users of `ruff` linter by adding compatible config in `pyproject.toml` (jaborsh)
@ -80,8 +80,8 @@ This upgrade requires running `evennia migrate` on your existing database
- [Fix][pull3690]: In searches, allow special 'here' and 'me' keywords only be valid queries - [Fix][pull3690]: In searches, allow special 'here' and 'me' keywords only be valid queries
unless current location and/or caller is in valid search candidates respectively (InspectorCaracal) unless current location and/or caller is in valid search candidates respectively (InspectorCaracal)
- [Fix][pull3694]: Funcparser swallowing rest of line after a `\`-escape (count-infinity) - [Fix][pull3694]: Funcparser swallowing rest of line after a `\`-escape (count-infinity)
- [Fix][pull3705]: Properly serialize `IntFlag` enum types (0xDEADFED5) - [Fix][pull3705]: Properly serialize `IntFlag` enum types (electroglyph)
- [Fix][pull3707]: Correct links in `about` command (0xDEADFED5) - [Fix][pull3707]: Correct links in `about` command (electroglyph)
- [Fix][pull3710]: Clean reduntant session clearin in `at_server_cold_start` (InspectorCaracal) - [Fix][pull3710]: Clean reduntant session clearin in `at_server_cold_start` (InspectorCaracal)
- [Fix][pull3711]: Usability improvements in the Discord integration (InspectorCaracal) - [Fix][pull3711]: Usability improvements in the Discord integration (InspectorCaracal)
- [Fix][pull3721]: Avoid loading cmdsets that don't need to be checked, avoiding - [Fix][pull3721]: Avoid loading cmdsets that don't need to be checked, avoiding
@ -97,7 +97,7 @@ This upgrade requires running `evennia migrate` on your existing database
- [Fix][pull3743]: Log full stack trace on failed object creation (aMiss-aWry) - [Fix][pull3743]: Log full stack trace on failed object creation (aMiss-aWry)
- [Fix][pull3747]: TutorialWorld bridge-room didn't correctly randomize weather effects (SpyrosRoum) - [Fix][pull3747]: TutorialWorld bridge-room didn't correctly randomize weather effects (SpyrosRoum)
- [Fix][pull3765]: Storing TickerHandler `store_key` in a db attribute would not - [Fix][pull3765]: Storing TickerHandler `store_key` in a db attribute would not
work correctly (0xDEADFED5) work correctly (electroglyph)
- [Fix][pull3753]: Make sure `AttributeProperty`s are initialized with default values also in parent class (JohnFi) - [Fix][pull3753]: Make sure `AttributeProperty`s are initialized with default values also in parent class (JohnFi)
- [Fix][pull3751]: The `access` and `inventory` commands would traceback if run on a character without an Account (EliasWatson) - [Fix][pull3751]: The `access` and `inventory` commands would traceback if run on a character without an Account (EliasWatson)
- [Fix][pull3768]: Make sure the `CmdCopy` command copies object categories, - [Fix][pull3768]: Make sure the `CmdCopy` command copies object categories,
@ -112,7 +112,7 @@ This upgrade requires running `evennia migrate` on your existing database
it caused an OnDemandHandler save error on reload. Will now clean up on save. (Griatch) it caused an OnDemandHandler save error on reload. Will now clean up on save. (Griatch)
used as the task's category (Griatch) used as the task's category (Griatch)
- Fix: Correct aws contrib's use of legacy django string utils (Griatch) - Fix: Correct aws contrib's use of legacy django string utils (Griatch)
- [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR, JohnFi, 0xDEADFED5, jaborsh, Problematic, BlaneWins - [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR, JohnFi, electroglyph, jaborsh, Problematic, BlaneWins
[pull3633]: https://github.com/evennia/evennia/pull/3633 [pull3633]: https://github.com/evennia/evennia/pull/3633
[pull3677]: https://github.com/evennia/evennia/pull/3677 [pull3677]: https://github.com/evennia/evennia/pull/3677
@ -234,7 +234,7 @@ Sep 29, 2024
- Feat: Support `scripts key:typeclass` to create global scripts - Feat: Support `scripts key:typeclass` to create global scripts
with dynamic keys (rather than just relying on typeclass' key) (Griatch) with dynamic keys (rather than just relying on typeclass' key) (Griatch)
- [Feat][pull3595]: Tweak Sqlite3 PRAGMAs for better performance (0xDEADFED5) - [Feat][pull3595]: Tweak Sqlite3 PRAGMAs for better performance (electroglyph)
- Feat: Make Sqlite3 PRAGMAs configurable via settings (Griatch) - Feat: Make Sqlite3 PRAGMAs configurable via settings (Griatch)
- [Feat][pull3592]: Revised German locationlization ('Du' instead of 'Sie', - [Feat][pull3592]: Revised German locationlization ('Du' instead of 'Sie',
cleanup) (Drakon72) cleanup) (Drakon72)
@ -243,7 +243,7 @@ with dynamic keys (rather than just relying on typeclass' key) (Griatch)
- [Feat][pull3588]: New `DefaultObject` hooks: `at_object_post_creation`, called once after - [Feat][pull3588]: New `DefaultObject` hooks: `at_object_post_creation`, called once after
first creation but after any prototypes have been applied, and first creation but after any prototypes have been applied, and
`at_object_post_spawn(prototype)`, called only after creation/update with a prototype (InspectorCaracal) `at_object_post_spawn(prototype)`, called only after creation/update with a prototype (InspectorCaracal)
- [Fix][pull3594]: Update/clean some Evennia dependencies (0xDEADFED5) - [Fix][pull3594]: Update/clean some Evennia dependencies (electroglyph)
- [Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch) - [Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch)
- [Fix][issue3590]: Make `examine` command properly show `strattr` type - [Fix][issue3590]: Make `examine` command properly show `strattr` type
Attribute values (Griatch) Attribute values (Griatch)
@ -257,7 +257,7 @@ did not add it to the handler's object (Griatch)
- [Fix][pull3605]: Correctly pass node kwargs through `@list_node` decorated evmenu nodes - [Fix][pull3605]: Correctly pass node kwargs through `@list_node` decorated evmenu nodes
(InspectorCaracal) (InspectorCaracal)
- [Fix][pull3597]: Address timing issue for testing `new_task_waiting_input `on - [Fix][pull3597]: Address timing issue for testing `new_task_waiting_input `on
Windows (0xDEADFED5) Windows (electroglyph)
- [Fix][pull3611]: Fix and update for Reports contrib (InspectorCaracal) - [Fix][pull3611]: Fix and update for Reports contrib (InspectorCaracal)
- [Fix][pull3625]: Lycanthropy tutorial page had some issues (feyrkh) - [Fix][pull3625]: Lycanthropy tutorial page had some issues (feyrkh)
- [Fix][pull3622]: Fix for examine command tracebacking with strvalue error - [Fix][pull3622]: Fix for examine command tracebacking with strvalue error
@ -303,10 +303,10 @@ Aug 11, 2024
- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports, - [Feat][pull3531]: New contrib; `in-game reports` for handling user reports,
bugs etc in-game (InspectorCaracal) bugs etc in-game (InspectorCaracal)
- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for - [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for
underline reset, italic/reset and strikethrough/reset (0xDEADFED5) underline reset, italic/reset and strikethrough/reset (electroglyph)
- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits - Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits
contrib can find and reference other Traits. (Griatch) contrib can find and reference other Traits. (Griatch)
- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5) - [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (electroglyph)
- [Fix][pull3571]: Better visual display of partial multimatch search results - [Fix][pull3571]: Better visual display of partial multimatch search results
(InspectorCaracal) (InspectorCaracal)
- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list - [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list
@ -316,8 +316,8 @@ underline reset, italic/reset and strikethrough/reset (0xDEADFED5)
- [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern) - [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern)
- [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern) - [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern)
- [Fix][pull3589]: Fix regex escaping in `utils.py` for future Python versions (hhsiao) - [Fix][pull3589]: Fix regex escaping in `utils.py` for future Python versions (hhsiao)
- [Docs]: Add True-color description for Colors documentation (0xDEADFED5) - [Docs]: Add True-color description for Colors documentation (electroglyph)
- [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5) - [Docs]: Doc fixes (Griatch, InspectorCaracal, electroglyph)
[pull3585]: https://github.com/evennia/evennia/pull/3585 [pull3585]: https://github.com/evennia/evennia/pull/3585
[pull3580]: https://github.com/evennia/evennia/pull/3580 [pull3580]: https://github.com/evennia/evennia/pull/3580

View file

@ -19,6 +19,7 @@ with [discussion forums][group] and a [discord server][chat] to help and support
pip install evennia pip install evennia
(windows users once: py -m evennia) (windows users once: py -m evennia)
(note: Windows users with multiple Python versions should prefer `py -3.11` instead of `python` when creating virtual environments)
evennia --init mygame evennia --init mygame
cd mygame cd mygame
evennia migrate evennia migrate

View file

@ -685,6 +685,11 @@ class ObjectDBManager(TypedObjectManager):
"or the setting is malformed." "or the setting is malformed."
) )
# db_key has NOT NULL constraint, convert None to empty string.
# at_first_save() will convert empty string to #dbref
if key is None:
key = ""
# create new instance # create new instance
new_object = typeclass( new_object = typeclass(
db_key=key, db_key=key,

View file

@ -244,6 +244,20 @@ class DefaultObjectTest(BaseEvenniaTest):
class TestObjectManager(BaseEvenniaTest): class TestObjectManager(BaseEvenniaTest):
"Test object manager methods" "Test object manager methods"
def test_create_object_with_none_key(self):
"""Test that create_object() handles key=None and key="" correctly."""
# Test with key=None - should convert to "" and then to #dbref
obj_none = ObjectDB.objects.create_object(key=None, location=self.room1)
self.assertIsNotNone(obj_none)
self.assertEqual(obj_none.key, f"#{obj_none.id}")
obj_none.delete()
# Test with key="" - should convert to #dbref
obj_empty = ObjectDB.objects.create_object(key="", location=self.room1)
self.assertIsNotNone(obj_empty)
self.assertEqual(obj_empty.key, f"#{obj_empty.id}")
obj_empty.delete()
def test_get_object_with_account(self): def test_get_object_with_account(self):
query = ObjectDB.objects.get_object_with_account("TestAccount").first() query = ObjectDB.objects.get_object_with_account("TestAccount").first()
self.assertEqual(query, self.char1) self.assertEqual(query, self.char1)

View file

@ -187,7 +187,7 @@ def homogenize_prototype(prototype, custom_keys=None):
"prototype-{}".format(hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]), "prototype-{}".format(hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]),
) )
homogenized["prototype_tags"] = homogenized.get("prototype_tags", []) homogenized["prototype_tags"] = homogenized.get("prototype_tags", [])
homogenized["prototype_locks"] = homogenized.get("prototype_lock", _PROTOTYPE_FALLBACK_LOCK) homogenized["prototype_locks"] = homogenized.get("prototype_locks", _PROTOTYPE_FALLBACK_LOCK)
homogenized["prototype_desc"] = homogenized.get("prototype_desc", "") homogenized["prototype_desc"] = homogenized.get("prototype_desc", "")
if "typeclass" not in prototype and "prototype_parent" not in prototype: if "typeclass" not in prototype and "prototype_parent" not in prototype:
homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS

View file

@ -402,6 +402,39 @@ class TestProtLib(BaseEvenniaTest):
match = protlib.search_prototype(self.prot["prototype_key"].upper()) match = protlib.search_prototype(self.prot["prototype_key"].upper())
self.assertEqual(match, [self.prot]) self.assertEqual(match, [self.prot])
def test_homogenize_prototype_locks_preserved(self):
"""Test that homogenize_prototype preserves custom prototype_locks. (Bug 3828)"""
from evennia.prototypes.prototypes import _PROTOTYPE_FALLBACK_LOCK
prot_with_locks = {
"prototype_key": "test_prot_with_locks",
"typeclass": "evennia.objects.objects.DefaultObject",
"prototype_locks": "spawn:perm(Builder);edit:perm(Admin)",
}
homogenized = protlib.homogenize_prototype(prot_with_locks)
self.assertEqual(
homogenized["prototype_locks"],
"spawn:perm(Builder);edit:perm(Admin)",
)
self.assertNotEqual(
homogenized["prototype_locks"],
_PROTOTYPE_FALLBACK_LOCK,
)
def test_homogenize_prototype_locks_default_fallback(self):
"""Test that homogenize_prototype uses default when prototype_locks not provided."""
from evennia.prototypes.prototypes import _PROTOTYPE_FALLBACK_LOCK
prot_without_locks = {
"prototype_key": "test_prot_without_locks",
"typeclass": "evennia.objects.objects.DefaultObject",
}
homogenized = protlib.homogenize_prototype(prot_without_locks)
self.assertEqual(
homogenized["prototype_locks"],
_PROTOTYPE_FALLBACK_LOCK,
)
class TestProtFuncs(BaseEvenniaTest): class TestProtFuncs(BaseEvenniaTest):
@override_settings(PROT_FUNC_MODULES=["evennia.prototypes.protfuncs"]) @override_settings(PROT_FUNC_MODULES=["evennia.prototypes.protfuncs"])
@ -1045,6 +1078,47 @@ class TestIssue2908(BaseEvenniaTest):
self.assertEqual(obj[0].location, self.room1) self.assertEqual(obj[0].location, self.room1)
class TestIssue3824(BaseEvenniaTest):
"""
Test that $obj, $objlist, $search, and $dbref callables work correctly when spawning prototypes.
Regression test for bug where 'prototype' kwarg was passed to search functions causing TypeError.
"""
def test_spawn_with_search_callables(self):
"""Test spawning prototype with $obj, $objlist, $search, and $dbref callables."""
# Setup: tag some objects for searching
self.room1.tags.add("test_location", category="zone")
self.room2.tags.add("test_location", category="zone")
self.obj1.tags.add("test_item", category="item_type")
# Create prototype using all search callables
prot = {
"prototype_key": "test_search_callables",
"typeclass": "evennia.objects.objects.DefaultObject",
"key": "test object",
"attr_obj": f"$obj({self.obj1.dbref})",
"attr_search": "$search(Char)",
"attr_objlist": "$objlist(test_location, category=zone, type=tag)",
"attr_dbref": f"$dbref({self.obj1.dbref})",
}
# This should not raise TypeError about 'prototype' kwarg
objs = spawner.spawn(prot, caller=self.char1)
self.assertEqual(len(objs), 1)
obj = objs[0]
# Verify all search callables worked correctly
self.assertEqual(obj.db.attr_obj, self.obj1)
self.assertEqual(obj.db.attr_search, self.char1)
self.assertEqual(obj.db.attr_dbref, self.obj1)
# attr_objlist should be a list or list-like object with 2 rooms
objlist = obj.db.attr_objlist
self.assertEqual(len(objlist), 2)
self.assertIn(self.room1, objlist)
self.assertIn(self.room2, objlist)
class TestIssue3101(EvenniaCommandTest): class TestIssue3101(EvenniaCommandTest):
""" """
Spawning and using create_object should store the same `typeclass_path` if using Spawning and using create_object should store the same `typeclass_path` if using

View file

@ -703,12 +703,17 @@ def _transform(func_name):
def wrapped(self, *args, **kwargs): def wrapped(self, *args, **kwargs):
replacement_string = _query_super(func_name)(self, *args, **kwargs) replacement_string = _query_super(func_name)(self, *args, **kwargs)
# Convert to sets for O(1) membership testing
code_indexes_set = set(self._code_indexes)
char_indexes_set = set(self._char_indexes)
to_string = [] to_string = []
char_counter = 0 char_counter = 0
for index in range(0, len(self._raw_string)): for index in range(0, len(self._raw_string)):
if index in self._code_indexes: if index in code_indexes_set:
to_string.append(self._raw_string[index]) to_string.append(self._raw_string[index])
elif index in self._char_indexes: elif index in char_indexes_set:
to_string.append(replacement_string[char_counter]) to_string.append(replacement_string[char_counter])
char_counter += 1 char_counter += 1
return ANSIString( return ANSIString(
@ -1028,10 +1033,12 @@ class ANSIString(str, metaclass=ANSIMeta):
return ANSIString("") return ANSIString("")
last_mark = slice_indexes[0] last_mark = slice_indexes[0]
# Check between the slice intervals for escape sequences. # Check between the slice intervals for escape sequences.
# Convert to set for O(1) membership testing
code_indexes_set = set(self._code_indexes)
i = None i = None
for i in slice_indexes[1:]: for i in slice_indexes[1:]:
for index in range(last_mark, i): for index in range(last_mark, i):
if index in self._code_indexes: if index in code_indexes_set:
string += self._raw_string[index] string += self._raw_string[index]
last_mark = i last_mark = i
try: try:
@ -1065,15 +1072,18 @@ class ANSIString(str, metaclass=ANSIMeta):
append_tail = self._get_interleving(item + 1) append_tail = self._get_interleving(item + 1)
else: else:
append_tail = "" append_tail = ""
item = self._char_indexes[item]
clean = self._raw_string[item] char_pos = self._char_indexes[item]
result = "" clean = self._raw_string[char_pos]
# Get the character they're after, and replay all escape sequences
# previous to it. code_indexes_set = set(self._code_indexes)
for index in range(0, item + 1):
if index in self._code_indexes: result_chars = [
result += self._raw_string[index] self._raw_string[index] for index in range(0, char_pos + 1) if index in code_indexes_set
]
result = "".join(result_chars)
return ANSIString(result + clean + append_tail, decoded=True) return ANSIString(result + clean + append_tail, decoded=True)
def clean(self): def clean(self):
@ -1153,7 +1163,9 @@ class ANSIString(str, metaclass=ANSIMeta):
# Plain string, no ANSI codes. # Plain string, no ANSI codes.
return code_indexes, list(range(0, len(self._raw_string))) return code_indexes, list(range(0, len(self._raw_string)))
# all indexes not occupied by ansi codes are normal characters # all indexes not occupied by ansi codes are normal characters
char_indexes = [i for i in range(len(self._raw_string)) if i not in code_indexes] code_indexes_set = set(code_indexes)
char_indexes = [i for i in range(len(self._raw_string)) if i not in code_indexes_set]
return code_indexes, char_indexes return code_indexes, char_indexes
def _get_interleving(self, index): def _get_interleving(self, index):
@ -1166,12 +1178,17 @@ class ANSIString(str, metaclass=ANSIMeta):
index = self._char_indexes[index - 1] index = self._char_indexes[index - 1]
except IndexError: except IndexError:
return "" return ""
# Convert to sets for O(1) membership testing
char_indexes_set = set(self._char_indexes)
code_indexes_set = set(self._code_indexes)
s = "" s = ""
while True: while True:
index += 1 index += 1
if index in self._char_indexes: if index in char_indexes_set:
break break
elif index in self._code_indexes: elif index in code_indexes_set:
s += self._raw_string[index] s += self._raw_string[index]
else: else:
break break

View file

@ -1131,12 +1131,12 @@ def funcparser_callable_search(*args, caller=None, access="control", **kwargs):
- "$search(beach, category=outdoors, type=tag) - "$search(beach, category=outdoors, type=tag)
""" """
# clean out funcparser-specific kwargs so we can use the kwargs for # clean out funcparser and protfunc_parser-specific kwargs so we can use the kwargs for
# searching # searching
search_kwargs = { search_kwargs = {
key: value key: value
for key, value in kwargs.items() for key, value in kwargs.items()
if key not in ("funcparser", "raise_errors", "type", "return_list") if key not in ("funcparser", "raise_errors", "type", "return_list", "prototype")
} }
return_list = str(kwargs.pop("return_list", "false")).lower() == "true" return_list = str(kwargs.pop("return_list", "false")).lower() == "true"

View file

@ -8,7 +8,16 @@ Test of the ANSI parsing and ANSIStrings.
from django.test import TestCase from django.test import TestCase
from evennia.utils.ansi import ANSIString as AN from evennia.utils.ansi import (
ANSIString as AN,
ANSI_RED,
ANSI_CYAN,
ANSI_YELLOW,
ANSI_GREEN,
ANSI_BLUE,
ANSI_HILITE,
ANSI_NORMAL,
)
class TestANSIString(TestCase): class TestANSIString(TestCase):
@ -20,7 +29,9 @@ class TestANSIString(TestCase):
self.example_raw = "|relectric |cboogaloo|n" self.example_raw = "|relectric |cboogaloo|n"
self.example_ansi = AN(self.example_raw) self.example_ansi = AN(self.example_raw)
self.example_str = "electric boogaloo" self.example_str = "electric boogaloo"
self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m" self.example_output = (
f"{ANSI_HILITE}{ANSI_RED}electric {ANSI_HILITE}{ANSI_CYAN}boogaloo{ANSI_NORMAL}"
)
def test_length(self): def test_length(self):
self.assertEqual(len(self.example_ansi), 17) self.assertEqual(len(self.example_ansi), 17)
@ -52,3 +63,135 @@ class TestANSIString(TestCase):
self.assertEqual(split2, split3, "Split 2 and 3 differ") self.assertEqual(split2, split3, "Split 2 and 3 differ")
self.assertEqual(split1, split2, "Split 1 and 2 differ") self.assertEqual(split1, split2, "Split 1 and 2 differ")
self.assertEqual(split1, split3, "Split 1 and 3 differ") self.assertEqual(split1, split3, "Split 1 and 3 differ")
def test_getitem_index_access(self):
"""Test individual character access via indexing"""
# Test accessing individual characters
self.assertEqual(self.example_ansi[0].clean(), "e")
self.assertEqual(self.example_ansi[9].clean(), "b")
self.assertEqual(self.example_ansi[-1].clean(), "o")
self.assertEqual(self.example_ansi[-2].clean(), "o")
# Verify ANSI codes are preserved when accessing characters
first_char = self.example_ansi[0]
self.assertTrue(isinstance(first_char, AN))
# First character should have red color code
self.assertIn(ANSI_RED, first_char.raw())
# Test character at color boundary (first character after color change)
ninth_char = self.example_ansi[9]
self.assertEqual(ninth_char.clean(), "b")
# Should have cyan color code
self.assertIn(ANSI_CYAN, ninth_char.raw())
def test_getitem_slice_access(self):
"""Test slice access"""
# Test basic slicing
substring = self.example_ansi[0:8]
self.assertEqual(substring.clean(), "electric")
self.assertTrue(isinstance(substring, AN))
# Test slicing with step
substring2 = self.example_ansi[9:17]
self.assertEqual(substring2.clean(), "boogaloo")
# Test negative indices
last_three = self.example_ansi[-3:]
self.assertEqual(last_three.clean(), "loo")
# Verify ANSI codes are preserved in slices
first_word = self.example_ansi[0:8]
self.assertIn(ANSI_RED, first_word.raw())
def test_getitem_edge_cases(self):
"""Test edge cases for indexing"""
# Test with string with no ANSI codes
plain = AN("plain text")
self.assertEqual(plain[0].clean(), "p")
self.assertEqual(plain[6].clean(), "t")
# Test with single character
single = AN("|rX|n")
self.assertEqual(len(single), 1)
self.assertEqual(single[0].clean(), "X")
# Test IndexError
with self.assertRaises(IndexError):
_ = self.example_ansi[100]
def test_upper_method(self):
"""Test upper() method"""
# Test basic upper with ANSI codes
result = self.example_ansi.upper()
self.assertEqual(result.clean(), "ELECTRIC BOOGALOO")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
self.assertIn(ANSI_CYAN, result.raw())
# Test with mixed case
mixed = AN("|rHeLLo |cWoRLd|n")
self.assertEqual(mixed.upper().clean(), "HELLO WORLD")
def test_lower_method(self):
"""Test lower() method"""
# Test basic lower with ANSI codes
upper_ansi = AN("|rELECTRIC |cBOOGALOO|n")
result = upper_ansi.lower()
self.assertEqual(result.clean(), "electric boogaloo")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
self.assertIn(ANSI_CYAN, result.raw())
def test_capitalize_method(self):
"""Test capitalize() method"""
# Test basic capitalize with ANSI codes
lower_ansi = AN("|relectric |cboogaloo|n")
result = lower_ansi.capitalize()
self.assertEqual(result.clean(), "Electric boogaloo")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
def test_swapcase_method(self):
"""Test swapcase() method"""
# Test basic swapcase with ANSI codes
mixed = AN("|rElEcTrIc |cBoOgAlOo|n")
result = mixed.swapcase()
self.assertEqual(result.clean(), "eLeCtRiC bOoGaLoO")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
self.assertIn(ANSI_CYAN, result.raw())
def test_transform_with_dense_ansi(self):
"""Test string transformation with ANSI codes between every character"""
# Simulate rainbow text with ANSI between each character
dense = AN("|rh|ce|yl|gl|bo|n")
self.assertEqual(dense.clean(), "hello")
# Test upper preserves all ANSI codes
upper_dense = dense.upper()
self.assertEqual(upper_dense.clean(), "HELLO")
self.assertTrue(isinstance(upper_dense, AN))
# Verify all color codes are still present
raw = upper_dense.raw()
self.assertIn(ANSI_RED, raw)
self.assertIn(ANSI_CYAN, raw)
self.assertIn(ANSI_YELLOW, raw)
self.assertIn(ANSI_GREEN, raw)
self.assertIn(ANSI_BLUE, raw)
def test_transform_without_ansi(self):
"""Test string transformation on plain strings"""
plain = AN("hello world")
self.assertEqual(plain.upper().clean(), "HELLO WORLD")
self.assertEqual(plain.lower().clean(), "hello world")
self.assertEqual(plain.capitalize().clean(), "Hello world")