Add a heavy-duty test for Sequin internals
Marco Ricci

Marco Ricci commited on 2026-07-04 23:22:58
Zeige 1 geänderte Dateien mit 378 Einfügungen und 1 Löschungen.


Add a heavy-duty hypothesis test (rule-based state machine) that
compares the state of our Sequin class against an as-literal-as-possible
port of the original JavaScript sequin module, included inline in the
test class.

(The heavy-duty marker and the state machine machinery are probably
overkill for this application, given that there is only one rule, and
that the test runs very fast.  Nevertheless, phrasing this as a state
machine makes the workings of the test very explicit: what are the state
transitions, what are the invariants, etc.)
... ...
@@ -16,10 +16,11 @@ from typing import TYPE_CHECKING, NamedTuple
16 16
 
17 17
 import hypothesis
18 18
 import pytest
19
-from hypothesis import strategies
19
+from hypothesis import stateful, strategies
20 20
 
21 21
 from derivepassphrase import sequin
22 22
 from tests.machinery import hypothesis as hypothesis_machinery
23
+from tests.machinery import pytest as pytest_machinery
23 24
 
24 25
 if TYPE_CHECKING:
25 26
     from collections.abc import Sequence
... ...
@@ -557,3 +558,379 @@ class TestSequin:
557 558
         """The sequin raises on invalid bit and integer sequences."""
558 559
         with pytest.raises(exc_type, match=exc_pattern):
559 560
             sequin.Sequin(sequence, is_bitstring=is_bitstring)
561
+
562
+
563
+class SequinStateMachine(stateful.RuleBasedStateMachine):
564
+    r"""A state machine for the sequin.
565
+
566
+    Compares the output of [`sequin.Sequin.generate`][] with the output
567
+    of a 1-to-1 translation of the original JavaScript Sequin code.
568
+
569
+    """
570
+
571
+    ORIGINAL_SOURCE = r"""
572
+        'use strict';
573
+
574
+        var Stream = function(sequence, bits) {
575
+          bits = bits || (sequence instanceof Buffer ? 8 : 1);
576
+          var binary = '', b, i, n;
577
+
578
+          for (i = 0, n = sequence.length; i < n; i++) {
579
+            b = this._get(sequence, i).toString(2);
580
+            while (b.length < bits) b = '0' + b;
581
+            binary = binary + b;
582
+          }
583
+          binary = binary.split('').map(function(b) { return parseInt(b, 2) });
584
+
585
+          this._bases = {'2': binary};
586
+        };
587
+
588
+        Stream.prototype.generate = function(n, base, inner) {
589
+          base = base || 2;
590
+
591
+          var value = n,
592
+              k = Math.ceil(Math.log(n) / Math.log(base)),
593
+              r = Math.pow(base, k) - n,
594
+              chunk;
595
+
596
+          loop: while (value >= n) {
597
+            chunk = this._shift(base, k);
598
+            if (!chunk) return inner ? n : null;
599
+
600
+            value = this._evaluate(chunk, base);
601
+
602
+            if (value >= n) {
603
+              if (r === 1) continue loop;
604
+              this._push(r, value - n);
605
+              value = this.generate(n, r, true);
606
+            }
607
+          }
608
+          return value;
609
+        };
610
+
611
+        Stream.prototype._get = function(sequence, i) {
612
+          return sequence.readUInt8 ? sequence.readUInt8(i) : sequence[i];
613
+        };
614
+
615
+        Stream.prototype._evaluate = function(chunk, base) {
616
+          var sum = 0,
617
+              i   = chunk.length;
618
+
619
+          while (i--) sum += chunk[i] * Math.pow(base, chunk.length - (i+1));
620
+          return sum;
621
+        };
622
+
623
+        Stream.prototype._push = function(base, value) {
624
+          this._bases[base] = this._bases[base] || [];
625
+          this._bases[base].push(value);
626
+        };
627
+
628
+        Stream.prototype._shift = function(base, k) {
629
+          var list = this._bases[base];
630
+          if (!list || list.length < k) return null;
631
+          else return list.splice(0,k);
632
+        };
633
+
634
+        module.exports = Stream;
635
+    """
636
+    """
637
+    The original JavaScript code: Sequin 0.1.1, Copyright 2017 James
638
+    Coglan.  Released under the following MIT License:
639
+
640
+        Copyright (c) 2012-2017 James Coglan
641
+
642
+        Permission is hereby granted, free of charge, to any person obtaining a copy of
643
+        this software and associated documentation files (the 'Software'), to deal in
644
+        the Software without restriction, including without limitation the rights to
645
+        use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
646
+        the Software, and to permit persons to whom the Software is furnished to do so,
647
+        subject to the following conditions:
648
+
649
+        The above copyright notice and this permission notice shall be included in all
650
+        copies or substantial portions of the Software.
651
+
652
+        THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
653
+        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
654
+        FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
655
+        COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
656
+        IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
657
+        CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
658
+    """
659
+
660
+    class Stream:  # pragma: no cover
661
+        def __init__(
662
+            self,
663
+            sequence: bytes | bytearray | Sequence[int],
664
+            bits: int | None = None,
665
+        ) -> None:
666
+            r"""Initialize the stream.
667
+
668
+            Corresponding JavaScript source:
669
+
670
+                var Stream = function(sequence, bits) {
671
+                  bits = bits || (sequence instanceof Buffer ? 8 : 1);
672
+                  var binary = '', b, i, n;
673
+
674
+                  for (i = 0, n = sequence.length; i < n; i++) {
675
+                    b = this._get(sequence, i).toString(2);
676
+                    while (b.length < bits) b = '0' + b;
677
+                    binary = binary + b;
678
+                  }
679
+                  binary = binary.split('').map(function(b) { return parseInt(b, 2) });
680
+
681
+                  this._bases = {'2': binary};
682
+                };
683
+
684
+            """
685
+            bits = (
686
+                bits
687
+                if bits is not None
688
+                else 8
689
+                if isinstance(sequence, (bytes, bytearray))
690
+                else 1
691
+            )
692
+            binary = ""
693
+            b: str
694
+            i: int
695
+            n = len(sequence)
696
+
697
+            for i in range(n):
698
+                b = "{:b}".format(self._get(sequence, i))  # noqa: UP032
699
+                while len(b) < bits:
700
+                    b = "0" + b
701
+                binary = binary + b  # noqa: PLR6104
702
+            binary_ = list(map(lambda b: int(b, 2), list(binary)))  # noqa: C417
703
+
704
+            self._bases = {2: binary_}
705
+
706
+        def generate(self, n: int, base: int = 2) -> int | None:
707
+            r"""Main method "generate".
708
+
709
+            Corresponding JavaScript source:
710
+
711
+                Stream.prototype.generate = function(n, base, inner) {
712
+                  base = base || 2;
713
+
714
+                  var value = n,
715
+                      k = Math.ceil(Math.log(n) / Math.log(base)),
716
+                      r = Math.pow(base, k) - n,
717
+                      chunk;
718
+
719
+                  loop: while (value >= n) {
720
+                    chunk = this._shift(base, k);
721
+                    if (!chunk) return inner ? n : null;
722
+
723
+                    value = this._evaluate(chunk, base);
724
+
725
+                    if (value >= n) {
726
+                      if (r === 1) continue loop;
727
+                      this._push(r, value - n);
728
+                      value = this.generate(n, r, true);
729
+                    }
730
+                  }
731
+                  return value;
732
+                };
733
+
734
+            ...except that we hardcode `inner` to False, for typing
735
+            reasons.  ([`_generate_inner`][] is the version with `inner`
736
+            hardcoded to True.)
737
+
738
+            """
739
+            value: int = n
740
+            k = math.ceil(math.log(n) / math.log(base))
741
+            r = pow(base, k) - n
742
+            chunk: Sequence[int] | None
743
+
744
+            while value >= n:
745
+                chunk = self._shift(base, k)
746
+                if not chunk:
747
+                    return None
748
+
749
+                value = self._evaluate(chunk, base)
750
+
751
+                if value >= n:
752
+                    if r == 1:
753
+                        continue
754
+                    self._push(r, value - n)
755
+                    value = self._generate_inner(n, r)
756
+            return value
757
+
758
+        def _generate_inner(self, n: int, base: int = 2) -> int:
759
+            r"""Helper method.
760
+
761
+            Corresponding JavaScript source:
762
+
763
+                Stream.prototype.generate = function(n, base, inner) {
764
+                  base = base || 2;
765
+
766
+                  var value = n,
767
+                      k = Math.ceil(Math.log(n) / Math.log(base)),
768
+                      r = Math.pow(base, k) - n,
769
+                      chunk;
770
+
771
+                  loop: while (value >= n) {
772
+                    chunk = this._shift(base, k);
773
+                    if (!chunk) return inner ? n : null;
774
+
775
+                    value = this._evaluate(chunk, base);
776
+
777
+                    if (value >= n) {
778
+                      if (r === 1) continue loop;
779
+                      this._push(r, value - n);
780
+                      value = this.generate(n, r, true);
781
+                    }
782
+                  }
783
+                  return value;
784
+                };
785
+
786
+            ...except that we hardcode `inner` to True, for typing
787
+            reasons.  ([`generate`][] is the version with `inner`
788
+            hardcoded to False.)
789
+
790
+            """
791
+            value: int = n
792
+            k = math.ceil(math.log(n) / math.log(base))
793
+            r = pow(base, k) - n
794
+            chunk: Sequence[int] | None
795
+
796
+            while value >= n:
797
+                chunk = self._shift(base, k)
798
+                if not chunk:
799
+                    return n
800
+
801
+                value = self._evaluate(chunk, base)
802
+
803
+                if value >= n:
804
+                    if r == 1:
805
+                        continue
806
+                    self._push(r, value - n)
807
+                    value = self._generate_inner(n, r)
808
+            return value
809
+
810
+        @staticmethod
811
+        def _get(sequence: bytes | bytearray | Sequence[int], i: int) -> int:
812
+            r"""Helper method.
813
+
814
+            Corresponding JavaScript source:
815
+
816
+                Stream.prototype._get = function(sequence, i) {
817
+                  return sequence.readUInt8 ? sequence.readUInt8(i) : sequence[i];
818
+                };
819
+
820
+            """
821
+            return (
822
+                sequence[i]  # noqa: RUF034
823
+                if isinstance(sequence, (bytes, bytearray))
824
+                else sequence[i]
825
+            )
826
+
827
+        @staticmethod
828
+        def _evaluate(chunk: Sequence[int], base: int) -> int:
829
+            r"""Helper method.
830
+
831
+            Corresponding JavaScript source:
832
+
833
+                Stream.prototype._evaluate = function(chunk, base) {
834
+                  var sum = 0,
835
+                      i   = chunk.length;
836
+
837
+                  while (i--) sum += chunk[i] * Math.pow(base, chunk.length - (i+1));
838
+                  return sum;
839
+                };
840
+
841
+            """
842
+            sum_ = 0
843
+            i = len(chunk)
844
+            while i > 0:
845
+                i -= 1
846
+                sum_ += chunk[i] * pow(base, len(chunk) - (i + 1))
847
+            return sum_
848
+
849
+        def _push(self, base: int, value: int) -> None:
850
+            r"""Helper method.
851
+
852
+            Corresponding JavaScript source:
853
+
854
+                Stream.prototype._push = function(base, value) {
855
+                  this._bases[base] = this._bases[base] || [];
856
+                  this._bases[base].push(value);
857
+                };
858
+
859
+            """
860
+            self._bases.setdefault(base, [])
861
+            self._bases[base].append(value)
862
+
863
+        def _shift(self, base: int, k: int) -> Sequence[int] | None:
864
+            r"""Helper method.
865
+
866
+            Corresponding JavaScript source:
867
+
868
+                Stream.prototype._shift = function(base, k) {
869
+                  var list = this._bases[base];
870
+                  if (!list || list.length < k) return null;
871
+                  else return list.splice(0,k);
872
+                };
873
+
874
+            """
875
+            list_ = self._bases[base]
876
+            if not list_ or len(list_) < k:
877
+                return None
878
+            else:  # noqa: RET505
879
+                result = list_[0:k]
880
+                list_[0:k] = []
881
+                return result
882
+
883
+    def __init__(self) -> None:
884
+        """Initialize this state machine."""
885
+        super().__init__()
886
+        self.reference_sequin: SequinStateMachine.Stream
887
+        self.sequin: sequin.Sequin
888
+
889
+    @stateful.initialize(
890
+        sequence=strategies.one_of(
891
+            strategies.lists(strategies.integers(0, 1)), strategies.binary()
892
+        )
893
+    )
894
+    def _init(self, sequence: bytes | bytearray | list[int]) -> None:
895
+        if isinstance(sequence, (bytes, bytearray)):
896
+            self.reference_sequin = SequinStateMachine.Stream(bytes(sequence))
897
+            self.sequin = sequin.Sequin(bytes(sequence), is_bitstring=False)
898
+        else:
899
+            self.reference_sequin = SequinStateMachine.Stream(sequence.copy())
900
+            self.sequin = sequin.Sequin(sequence.copy(), is_bitstring=True)
901
+
902
+    @staticmethod
903
+    def normalize_bases_structure(
904
+        bases: dict[int, list[int]] | dict[int, collections.deque[int]],
905
+    ) -> list[tuple[int, tuple[int, ...]]]:
906
+        return sorted((k, tuple(v)) for k, v in bases.items() if v)
907
+
908
+    @stateful.invariant()
909
+    def check_sequin_state(self) -> None:
910
+        reference_bases = self.reference_sequin._bases
911
+        sequin_bases = self.sequin.bases
912
+        assert self.normalize_bases_structure(
913
+            reference_bases
914
+        ) == self.normalize_bases_structure(sequin_bases)
915
+
916
+    @stateful.rule(n=strategies.integers(1, 128))
917
+    def generate(self, n: int) -> None:
918
+        value1 = self.reference_sequin.generate(n)
919
+        try:
920
+            value2 = self.sequin.generate(n)
921
+        except sequin.SequinExhaustedError:
922
+            value2 = None
923
+        if n == 1:
924
+            # As per upstream's own tests, Sequin.generate(1) should
925
+            # return 0, and we do.  But the original JavaScript version
926
+            # returns `null`.  Therefore, just assert that both are
927
+            # equally falsy.
928
+            assert not value1
929
+            assert not value2
930
+        else:
931
+            assert value1 == value2
932
+
933
+
934
+@pytest_machinery.heavy_duty
935
+class TestStateAgainstReferenceSequin(SequinStateMachine.TestCase):  # type: ignore[misc,valid-type]
936
+    pass
560 937