Repository: todofixthis/filters

Issue:
Add `suffix` to `MaxBytes`
`MaxBytes` accepts a `prefix` to prepend to truncated values; it should also have a `suffix` (e.g., `'...'`).

- [x] Tests
- [x] Implementation
- [x] Documentation

Discussion:

Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index 0355770..92655ac 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
 include LICENCE.txt
-recursive-include test *.py
+graft test
+prune **/__pycache__
diff --git a/README.rst b/README.rst
index cc3e739..6ea80e2 100644
--- a/README.rst
+++ b/README.rst
@@ -130,7 +130,11 @@ Install the package with the ``test-runner`` extra to set up the necessary
 dependencies, and then you can run the tests with the ``tox`` command::
 
    pip install -e .[test-runner]
-   tox -p all
+   tox -p
+
+To run tests in the current virtualenv::
+
+   python -m unittest
 
 Documentation
 -------------
diff --git a/docs/simple_filters.rst b/docs/simple_filters.rst
index a5fa61e..aa9c1e6 100644
--- a/docs/simple_filters.rst
+++ b/docs/simple_filters.rst
@@ -600,41 +600,118 @@ max value, set ``exclusive=True`` in the filter's initialiser:
 
 MaxBytes
 --------
-Truncates a unicode string to a max number of bytes.  When converting to a
-multibyte encoding (e.g., UTF-8), the filter will truncate additional bytes as
-needed to avoid orphaned sequences (see example below).
+Checks that a string will fit into a max number of bytes when encoded (using
+UTF-8 by default).
 
 .. important::
 
    The resulting value will be a byte string (``bytes`` type), not a unicode
    string!
 
+.. code-block:: python
+
+      import filters as f
+
+      runner = f.FilterRunner(f.MaxBytes(25), 'Γειάσου Κόσμε')
+      assert runner.is_valid() is True
+      assert runner.cleaned_data ==\
+        b'\xce\x93\xce\xb5\xce\xb9\xce\xac\xcf\x83\xce\xbf' \
+        b'\xcf\x85 \xce\x9a\xcf\x8c\xcf\x83\xce\xbc\xce\xb5'
+
+      runner = f.FilterRunner(f.MaxBytes(24), 'Γειάσου Κόσμε')
+      assert runner.is_valid() is False
+      assert runner.cleaned_data is None
+
+Instead of treating too-long values as invalid, you can configure the filter to
+truncate them instead:
+
 .. code-block:: python
 
    import filters as f
 
-   runner = f.FilterRunner(f.MaxBytes(24), 'Γειάσου Κόσμε')
-   # Value is too long, so ``is_valid()`` returns ``False``.
-   assert runner.is_valid() is False
-   # Note that the resulting value is truncated to 23 bytes instead of 24, so
-   # as not to orphan a multibyte sequence.
+   runner = f.FilterRunner(f.MaxBytes(22, truncate=True), 'हैलो वर्ल्ड')
+   # Truncated values are considered valid.
+   assert runner.is_valid() is True
    assert runner.cleaned_data ==\
-       b'\xce\x93\xce\xb5\xce\xb9\xce\xac\xcf\x83\xce\xbf' \
-       b'\xcf\x85 \xce\x9a\xcf\x8c\xcf\x83\xce\xbc'
+       b'\xe0\xa4\xb9\xe0\xa5\x88\xe0\xa4\xb2\xe0' \
+       b'\xa5\x8b \xe0\xa4\xb5\xe0\xa4\xb0\xe0\xa5\x8d'
 
-.. tip::
-
-   If you just want to validate the length of the input and don't need to
-   waste CPU cycles truncating too-long values, you can provide `truncate=False`
-   to the filter's initialiser:
+.. note::
+   When truncating with a multibyte encoding (e.g., UTF-8), the filter may
+   remove additional bytes as needed to avoid orphaned sequences:
 
    .. code-block:: python
 
       import filters as f
 
-      runner = f.FilterRunner(f.MaxBytes(24, truncate=False), 'Γειάσου Κόσμε')
-      assert runner.is_valid() is False
-      assert runner.cleaned_data is None
+      runner = f.FilterRunner(f.MaxBytes(21, truncate=True), 'हैलो वर्ल्ड')
+      assert runner.is_valid() is True
+      # Result is truncated to 19 bytes instead of 21, so as not to orphan a
+      # multibyte sequence.
+      assert len(runner.cleaned_data) == 19
+
+You can configure the filter to apply a prefix and/or suffix to the value when
+truncating:
+
+.. code-block:: python
+
+   import filters as f
+
+   # Apply a prefix to truncated values:
+   runner = f.FilterRunner(
+       f.MaxBytes(12, truncate=True, prefix='(more) '),
+       'Hello, world!'
+   )
+   assert runner.is_valid() is True
+   # The length of the prefix is taken into account, so that the result is still
+   # 12 bytes long.
+   assert runner.cleaned_data == b'(more) Hello'
+
+   # Apply a suffix to truncated values:
+   runner = f.FilterRunner(
+       f.MaxBytes(12, truncate=True, suffix='...'),
+       'Hello, world!',
+   )
+   assert runner.is_valid() is True
+   assert runner.cleaned_data == b'Hello, wo...'
+
+   # Apply both, why not..
+   runner = f.FilterRunner(
+       f.MaxBytes(12, truncate=True, prefix='->', suffix='<-'),
+       'Hello, world!',
+   )
+   assert runner.is_valid() is True
+   assert runner.cleaned_data == b'->Hello, w<-'
+
+By default, the filter uses UTF-8; if you need to use a different encoding, you
+can specify it when initialising the filter:
+
+.. code-block:: python
+
+   import filters as f
+
+   runner = f.FilterRunner(
+       f.MaxBytes(32, truncate=True, encoding='utf-16'),
+       'kia ora e te ao whānui',
+   )
+   assert runner.is_valid() is True
+   assert runner.cleaned_data ==\
+       b'\xff\xfek\x00i\x00a\x00 \x00o\x00r\x00a\x00' \
+       b' \x00e\x00 \x00t\x00e\x00 \x00a\x00o\x00'
+
+   # Prefix and suffix also work with alternate encodings.
+   runner = f.FilterRunner(
+       f.MaxBytes(40, truncate=True, prefix='[अधिक] ', suffix=' (अधिक)', encoding='utf-16'),
+       'मैं अपने आप से ऐसा क्यों करता हूं?',
+   )
+   assert runner.is_valid() is True
+   assert runner.cleaned_data == (
+       b"\xff\xfe"                           # BOM
+       b"[\x00\x05\t'\t?\t\x15\t]\x00 \x00"  # Prefix
+       b'.\tH\t\x02\t \x00\x05\t'            # Truncated string
+       b" \x00(\x00\x05\t'\t?\t\x15\t)\x00"  # Suffix
+   )
+   assert len(runner.cleaned_data) == 40
 
 MaxLength
 ---------
diff --git a/filters/string.py b/filters/string.py
index d36d8ef..a850f3b 100644
--- a/filters/string.py
+++ b/filters/string.py
@@ -231,8 +231,9 @@ class MaxBytes(BaseFilter):
     Ensures that an incoming string value is small enough to fit into a
     specified number of bytes when encoded.
 
-    Note:  The resulting value is a byte string, even if you provide a
-    unicode.
+    .. note::
+
+        The resulting value is always byte string.
     """
     CODE_TOO_LONG = 'too_long'
 
@@ -245,8 +246,9 @@ class MaxBytes(BaseFilter):
     def __init__(
             self,
             max_bytes: int,
-            truncate: bool = True,
+            truncate: bool = False,
             prefix: str = '',
+            suffix: str = '',
             encoding: str = 'utf-8',
     ) -> None:
         """
@@ -254,16 +256,30 @@ class MaxBytes(BaseFilter):
             Max number of bytes to allow.
 
         :param truncate:
-            Whether to truncate values that are too long.
+            How to handle values that are too long:
 
-            Set this to ``False`` to save system resources when you
-            know that you will reject values that are too long.
+            - ``truncate is True``:  Return truncated string.
+            - ``truncate is False``:  Treat as invalid value.
 
         :param prefix:
             Prefix to apply to truncated values.
 
+            The prefix will count towards the number of bytes, so even with a
+            prefix the resulting string will not exceed ``max_bytes`` in
+            length.
+
+            Ignored when the incoming value is short enough, or when
+            ``truncate is False``.
+
+        :param suffix:
+            Suffix to apply to truncated values.
+
+            The suffix will count towards the number of bytes, so even with a
+            suffix the resulting string will not exceed ``max_bytes`` in
+            length.
+
             Ignored when the incoming value is short enough, or when
-            ``truncate`` is ``False``.
+            ``truncate is False``.
 
         :param encoding:
             The character encoding to check against.
@@ -275,6 +291,7 @@ class MaxBytes(BaseFilter):
         self.encoding = encoding
         self.max_bytes = max_bytes
         self.prefix = prefix
+        self.suffix = suffix
         self.truncate = truncate
 
     def __str__(self):
@@ -304,57 +321,72 @@ class MaxBytes(BaseFilter):
         if self._has_errors:
             return None
 
-        str_value = value.encode(self.encoding)
+        bytes_value = value.encode(self.encoding)
 
-        if len(str_value) > self.max_bytes:
-            replacement = (
-                self.truncate_string(
-                    # Ensure that we convert back to unicode before
-                    # adding the prefix, just in case `self.encoding`
-                    # indicates a codec that uses a BOM.
-                    value=self.prefix + value,
-
-                    max_bytes=self.max_bytes,
-                    encoding=self.encoding,
-                )
-                if self.truncate
-                else None
-            )
+        if self.truncate:
+            # Truncated values are considered valid.
+            return self.truncate_bytes(bytes_value)
 
+        if len(bytes_value) > self.max_bytes:
+            # Else, too-long values are invalid.
             return self._invalid_value(
                 value=value,
                 reason=self.CODE_TOO_LONG,
-                replacement=replacement,
-
                 context={
                     'encoding': self.encoding,
                     'max_bytes': self.max_bytes,
-                    'prefix': self.prefix,
                     'truncate': self.truncate,
                 },
             )
 
-        return str_value
+        return bytes_value
 
-    @staticmethod
-    def truncate_string(value: str, max_bytes: int, encoding: str) -> bytes:
+    def truncate_bytes(self, bytes_value: bytes) -> bytes:
         """
-        Truncates a string value to the specified number of bytes.
+        Truncates a too-long bytes value to the specified number of bytes,
+        using the filter's current configuration.
 
         :return:
             Returns bytes, truncated to the correct length.
 
-            Note: Might be a bit shorter than `max_bytes`, to avoid
+            Note: Might be a bit shorter than ``self.max_bytes``, to avoid
             orphaning a multibyte sequence.
         """
-        # Convert to bytearray so that we get the same handling in
-        # Python 2 and Python 3.
-        bytes_ = bytearray(value.encode(encoding))
+        if len(bytes_value) <= self.max_bytes:
+            return bytes_value
+
+        # Note that ``self.encoding`` may indicate a codec that
+        # uses a BOM, so we have to do a little extra work to make
+        # sure we don't insert extra BOMs in the resulting value.
+        bom = len(''.encode(self.encoding))
+
+        # Prefix can be prepended right away.
+        if self.prefix:
+            bytes_value = (
+                    bytes_value[0:bom] +
+                    self.prefix.encode(self.encoding)[bom:] +
+                    bytes_value[bom:]
+            )
+
+        # Suffix has to be tucked away for later (otherwise, the first thing
+        # we'd truncate would be the suffix!).
+        encoded_suffix = (
+            self.suffix.encode(self.encoding)[bom:]
+            if self.suffix
+            else b''
+        )
+
+        # Ensure we leave enough space for the suffix.
+        target_bytes = self.max_bytes - len(encoded_suffix)
+
+        # Edge case where ``self.max_bytes`` is so tiny that we can't even fit
+        # the entire prefix+suffix into the end result.
+        if target_bytes < 1:
+            return b''
 
         # Truncating the value is a bit tricky, as we have to be
         # careful not to leave an unterminated multibyte sequence.
-
-        if encoding.lower() in ['utf-8', 'utf8']:
+        if self.encoding.lower() in {'utf-8', 'utf8'}:
             #
             # This code works a bit faster than the generic routine
             # (see below) because we only have to inspect up to 4
@@ -363,7 +395,7 @@ class MaxBytes(BaseFilter):
             #
             # But, it only works for UTF-8.
             #
-            truncated = bytes_[0:max_bytes]
+            truncated = bytes_value[0:target_bytes]
 
             # Walk backwards through the string until we hit certain
             # sequences.
@@ -399,7 +431,7 @@ class MaxBytes(BaseFilter):
                 # Else, we have a continuation byte.  Continue walking
                 # backwards through the string.
 
-            return truncated
+            return truncated + encoded_suffix
 
         else:
             trim = 0
@@ -407,26 +439,26 @@ class MaxBytes(BaseFilter):
                 # Progressively chop bytes off the end of the string
                 # until we have something that can be successfully
                 # decoded using the specified encoding.
-                truncated = bytes_[0:max_bytes - trim]
+                truncated = bytes_value[0:target_bytes - trim]
 
                 try:
-                    truncated.decode(encoding)
+                    truncated.decode(self.encoding)
                 except UnicodeDecodeError:
                     trim += 1
                 else:
-                    return bytes(truncated)
+                    return bytes(truncated) + encoded_suffix
 
                 # We should never get here, but just in case, we need
                 # to ensure the loop eventually terminates (Python
                 # won't error if ``max_bytes - trim`` goes negative,
                 # since the slice operator accepts negative values).
-                if trim >= max_bytes:
+                if trim >= target_bytes:
                     raise ValueError(
-                        'Unable to truncate {bytes_!r} to {max_bytes} '
+                        'Unable to truncate {bytes_value!r} to {target_bytes} '
                         'bytes when encoded using {encoding}.'.format(
-                            bytes_=bytes_,
-                            max_bytes=max_bytes,
-                            encoding=encoding,
+                            bytes_value=bytes_value,
+                            target_bytes=target_bytes,
+                            encoding=self.encoding,
                         ),
                     )
 
diff --git a/tox.ini b/tox.ini
index 428abf2..aefb8bb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,2 +1,5 @@
 [tox]
 envlist = py3{11,10,9}
+
+[testenv]
+commands = python -m unittest


Test patch:
diff --git a/test/complex_test.py b/test/test_complex.py
similarity index 100%
rename from test/complex_test.py
rename to test/test_complex.py
diff --git a/test/extensions_test.py b/test/test_extensions.py
similarity index 100%
rename from test/extensions_test.py
rename to test/test_extensions.py
diff --git a/test/handlers_test.py b/test/test_handlers.py
similarity index 100%
rename from test/handlers_test.py
rename to test/test_handlers.py
diff --git a/test/macros_test.py b/test/test_macros.py
similarity index 100%
rename from test/macros_test.py
rename to test/test_macros.py
diff --git a/test/number_test.py b/test/test_number.py
similarity index 100%
rename from test/number_test.py
rename to test/test_number.py
diff --git a/test/simple_test.py b/test/test_simple.py
similarity index 100%
rename from test/simple_test.py
rename to test/test_simple.py
diff --git a/test/string_test.py b/test/test_string.py
similarity index 86%
rename from test/string_test.py
rename to test/test_string.py
index 97cf94c..6eccb14 100644
--- a/test/string_test.py
+++ b/test/test_string.py
@@ -8,7 +8,7 @@ import regex
 
 import filters as f
 from filters.test import BaseFilterTestCase
-from test.simple_test import Bytesy, Unicody
+from test.test_simple import Bytesy, Unicody
 
 
 class Base64DecodeTestCase(BaseFilterTestCase):
@@ -641,7 +641,7 @@ class MaxBytesTestCase(BaseFilterTestCase):
 
         Use ``Required | MaxBytes`` if you want to reject null values.
         """
-        self.assertFilterPasses(self._filter(None, max_bytes=65535))
+        self.assertFilterPasses(self._filter(None, max_bytes=1))
 
     def test_pass_string_short(self):
         """
@@ -656,81 +656,150 @@ class MaxBytesTestCase(BaseFilterTestCase):
 
     def test_pass_string_short_with_prefix(self):
         """
-        The filter is configured to apply a prefix to values that are
-        too long, but the incoming value is a unicode that is short
-        enough.
+        The filter is configured to apply a prefix to values that are too long,
+        but the incoming value is a unicode string that is short enough.
         """
         # If we were to apply the prefix to the incoming string, it
         # would be too long to fit, but the filter will only apply
         # the prefix if the value needs to be truncated.
         self.assertFilterPasses(
-            self._filter('Γειάσου Κόσμε', max_bytes=25, prefix='σφάλμα:'),
+            self._filter(
+                'Γειάσου Κόσμε',
+                max_bytes=25,
+                truncate=True,
+                prefix='σφάλμα:',
+            ),
+
+            # The filter always returns bytes.
+            'Γειάσου Κόσμε'.encode('utf-8'),
+        )
+
+    def test_pass_string_short_with_suffix(self):
+        """
+        The filter is configured to apply a suffix to values that are too long,
+        but the incoming value is a unicode string that is short enough.
+        """
+        self.assertFilterPasses(
+            self._filter(
+                'Γειάσου Κόσμε',
+                max_bytes=25,
+                truncate=True,
+                suffix=' (σφάλμα)',
+            ),
 
             # The filter always returns bytes.
             'Γειάσου Κόσμε'.encode('utf-8'),
         )
 
-    def test_fail_string_long(self):
+    def test_fail_string_too_long(self):
         """
         The incoming value is a string that is too long.
         """
         self.assertFilterErrors(
             self._filter('Γειάσου Κόσμε', max_bytes=24),
             [f.MaxBytes.CODE_TOO_LONG],
+        )
+
+    def test_pass_string_truncated(self):
+        """
+        The incoming value is a string that is too long, and the filter is
+        configured to truncate it.
+        """
+        self.assertFilterPasses(
+            self._filter('Γειάσου Κόσμε', max_bytes=24, truncate=True),
 
             # Note that the resulting value is truncated to 23 bytes
             # instead of 24, so as not to orphan a multibyte
             # sequence.
-            expected_value=
             b'\xce\x93\xce\xb5\xce\xb9\xce\xac\xcf\x83\xce\xbf'
             b'\xcf\x85 \xce\x9a\xcf\x8c\xcf\x83\xce\xbc',
         )
 
-    def test_fail_string_long_with_prefix(self):
+    def test_pass_string_truncated_with_prefix(self):
         """
         The incoming value is a string that is too long, and the
         filter is configured to apply a prefix before truncating.
         """
-        self.assertFilterErrors(
-            self._filter('Γειάσου Κόσμε', max_bytes=24, prefix='σφάλμα:'),
-            [f.MaxBytes.CODE_TOO_LONG],
+        self.assertFilterPasses(
+            self._filter(
+                'Γειάσου Κόσμε',
+                max_bytes=24,
+                truncate=True,
+                prefix='σφάλμα:'
+            ),
 
             # Note that the prefix reduces the number of bytes
             # available when truncating the value.
             expected_value=
-            b'\xcf\x83\xcf\x86\xce\xac\xce\xbb\xce\xbc\xce\xb1:'
+            b'\xcf\x83\xcf\x86\xce\xac\xce\xbb\xce\xbc\xce\xb1:'  # Prefix
             b'\xce\x93\xce\xb5\xce\xb9\xce\xac\xcf\x83'
         )
 
-    def test_fail_string_long_no_truncate(self):
+    def test_pass_string_truncated_with_suffix(self):
         """
-        The incoming value is a string that is too long, and the
-        filter is configured not to truncate values.
+        The incoming value is a string that is too long, and the filter is
+        configured to apply a suffix after truncating.
         """
-        self.assertFilterErrors(
-            self._filter('Γειάσου Κόσμε', max_bytes=24, truncate=False),
-            [f.MaxBytes.CODE_TOO_LONG],
+        self.assertFilterPasses(
+            self._filter(
+                'หวัดดีชาวโลก',
+                max_bytes=30,
+                truncate=True,
+                suffix=' (อีก)',
+            ),
 
-            # When the filter is configured with `truncate=False`, it
-            # returns `None` instead of truncating too-long values.
-            expected_value=None,
+            expected_value=
+            b'\xe0\xb8\xab\xe0\xb8\xa7\xe0\xb8\xb1'
+            b'\xe0\xb8\x94\xe0\xb8\x94\xe0\xb8\xb5'
+            b' (\xe0\xb8\xad\xe0\xb8\xb5\xe0\xb8\x81)'  # Suffix
         )
 
-    def test_fail_string_tiny_max_bytes(self):
+    def test_pass_string_truncated_max_bytes_param_too_small(self):
         """
-        The filter is configured with a `max_bytes` so tiny that it is
+        The filter is configured with a ``max_bytes`` so tiny that it is
         impossible to fit any multibyte sequences into a truncated
         string.
+
+        This will probably never happen outside of this unit test, but if
+        there's one thing I've learned, it's that customers never walk into a
+        bar and simply order a beer.
         """
-        self.assertFilterErrors(
-            self._filter('你好，世界！', max_bytes=2),
-            [f.MaxBytes.CODE_TOO_LONG],
+        self.assertFilterPasses(
+            self._filter(
+                '你好，世界！',
+                max_bytes=2,
+                truncate=True,
+                prefix='更多',
+                suffix='更多',
+            ),
 
             # The filter returns an empty string, not `None`.
             expected_value=b'',
         )
 
-    def test_pass_string_alt_encoding(self):
+    def test_pass_string_truncated_max_bytes_param_almost_too_small(self):
+        """
+        The filter is configured with ``max_bytes`` so tiny that it is
+        impossible to fit any multibyte sequences into a truncated string...
+        but just big enough to fit in some of the prefix+suffix.
+
+        Why do I do this to myself?
+        """
+        self.assertFilterPasses(
+            self._filter(
+                '你好，世界！',
+                max_bytes=3,
+                truncate=True,
+                prefix='->',
+                suffix='<-',
+            ),
+
+            # The suffix has priority over the prefix.
+            # Because I had to pick one :shrug:
+            expected_value=b'-<-',
+        )
+
+    def test_pass_string_short_alt_encoding(self):
         """
         The filter is configured to use an encoding other than UTF-8,
         and the incoming value is a string that is short enough.
@@ -740,6 +809,7 @@ class MaxBytesTestCase(BaseFilterTestCase):
                 'Γειάσου Κόσμε',
 
                 max_bytes=13,
+                truncate=True,
                 encoding='iso-8859-7',
             ),
 
@@ -748,43 +818,56 @@ class MaxBytesTestCase(BaseFilterTestCase):
             b'\xc3\xe5\xe9\xdc\xf3\xef\xf5 \xca\xfc\xf3\xec\xe5',
         )
 
-    def test_fail_string_alt_encoding(self):
+    def test_fail_string_too_long_alt_encoding_has_bom(self):
         """
-        The filter is configured to use an encoding other that UTF-8,
+        The filter is configured to use an encoding that uses a BOM,
         and the incoming value is a string that is too long.
         """
         self.assertFilterErrors(
             self._filter(
                 'Γειάσου Κόσμε',
 
-                max_bytes=13,
+                max_bytes=27,
                 encoding='utf-16',
             ),
             [f.MaxBytes.CODE_TOO_LONG],
+        )
+
+    def test_pass_string_truncated_alt_encoding_has_bom(self):
+        """
+        The filter is configured to use an encoding that uses a BOM,
+        and the incoming value is a string that will be truncated.
+        """
+        self.assertFilterPasses(
+            self._filter(
+                'Γειάσου Κόσμε',
+
+                max_bytes=13,
+                truncate=True,
+                encoding='utf-16',
+            ),
 
             # End result is only 12 bytes because UTF-16 uses 2 bytes
-            # per character.
-            #
-            # Technically, it's only 10 bytes if you don't count the
-            # BOM.
+            # per character, plus 2 bytes for the BOM.
             expected_value=
-            b'\xff\xfe\x93\x03\xb5\x03\xb9\x03\xac\x03\xc3\x03',
+            b'\xff\xfe'  # BOM
+            b'\x93\x03\xb5\x03\xb9\x03\xac\x03\xc3\x03'  # Truncated string
         )
 
-    def test_fail_string_alt_encoding_with_prefix(self):
+    def test_pass_string_truncated_alt_encoding_has_bom_with_prefix(self):
         """
-        The filter is configured to use an encoding other than UTF-8
-        and apply a prefix to truncated values.
+        The filter is configured to use an encoding that uses a BOM,
+        and to apply a prefix to truncated values.
         """
-        self.assertFilterErrors(
+        self.assertFilterPasses(
             self._filter(
                 'Γειάσου Κόσμε',
 
                 max_bytes=18,
+                truncate=True,
                 prefix='σφάλμα:',
                 encoding='utf-16',
             ),
-            [f.MaxBytes.CODE_TOO_LONG],
 
             # Note that the BOM is only applied once.
             expected_value=
@@ -797,6 +880,68 @@ class MaxBytesTestCase(BaseFilterTestCase):
             b'\x93\x03',
         )
 
+    def test_pass_string_truncated_alt_encoding_has_bom_with_suffix(self):
+        """
+        The filter is configured to use an encoding that uses a BOM,
+        and to apply a suffix to the truncated values.
+        """
+        self.assertFilterPasses(
+            self._filter(
+                'หวัดดีชาวโลก',
+                max_bytes=20,
+                truncate=True,
+                suffix=' (อีก)',
+                encoding='utf-16'
+            ),
+
+            expected_value=
+            b"\xff\xfe"  # BOM
+            b"+\x0e'\x0e1\x0e"  # Truncated string
+            b' \x00(\x00-\x0e5\x0e\x01\x0e)\x00'  # Suffix
+        )
+
+    def test_pass_string_truncated_alt_encoding_has_bom_with_prefix_and_suffix(
+            self):
+        """
+        Because my life wasn't hard enough already...
+        """
+        self.assertFilterPasses(
+            self._filter(
+                'मैं अपने आप से ऐसा क्यों करता हूं?',
+                max_bytes=40,
+                truncate=True,
+                prefix='[अधिक] ',
+                suffix=' (अधिक)',
+                encoding='utf-16'
+            ),
+
+            expected_value=
+            b"\xff\xfe"  # BOM
+            b"[\x00\x05\t'\t?\t\x15\t]\x00 \x00"  # Prefix
+            b'.\tH\t\x02\t \x00\x05\t'  # Truncated string
+            b" \x00(\x00\x05\t'\t?\t\x15\t)\x00"  # Suffix
+        )
+
+    def test_pass_string_truncated_max_bytes_param_almost_too_small_alt_encoding_has_bom(
+            self):
+        """
+        Unrealistically tiny ``max_bytes``, part 2: the revenge!
+        """
+        self.assertFilterPasses(
+            self._filter(
+                '你好，世界！',
+                max_bytes=4,
+                truncate=True,
+                prefix='>',
+                suffix='<',
+                encoding='utf-16',
+            ),
+
+            # The suffix has priority over the prefix.
+            # Because I had to pick one :shrug:
+            expected_value=b'\xff\xfe<\x00',
+        )
+
     def test_pass_bytes_short(self):
         """
         The incoming value is a byte string that is short enough.
@@ -822,6 +967,21 @@ class MaxBytesTestCase(BaseFilterTestCase):
                 max_bytes=17,
             ),
             [f.MaxBytes.CODE_TOO_LONG],
+        )
+
+    def test_pass_bytes_truncated(self):
+        """
+        The incoming value is a byte string that is too long, and the filter is
+        configured to truncate.
+        """
+        self.assertFilterPasses(
+            self._filter(
+                b'\xe4\xbd\xa0\xe5\xa5\xbd\xef\xbc\x8c'
+                b'\xe4\xb8\x96\xe7\x95\x8c\xef\xbc\x81',
+
+                max_bytes=17,
+                truncate=True,
+            ),
 
             # Note that the resulting value is truncated to 15 bytes
             # instead of 17, so as not to orphan a multibyte