Repository: DDMAL/CantusDB

Issue:
We should create a hook that updates a chant/sequence's `incipit` field when the chant/sequence is saved
We don't keep chants' `incipit`s up-to-date, yet `ajax_search_bar` searches the `incipit` field. So, as users add chants, they will not appear in search results while the user is typing in the search bar.

In general, we need to do something with incipits: either update them based on some rule in `signals.py`, or calculate them on-the-fly. See #361, and perhaps one or two other issues. Recent times we've met with her, Debra has mentioned wanting to have a consistent way to handle incipits.


Discussion:
I thought the consensus was to create an incipit off the full text of each chant (when present), so that it is consistent with whatever the fulltext is. I suppose this would be a bit like the best-text-for-syllabification, in that when there is no fulltext one would keep the current incipit value, to be replaced if the full text is ever added. 
Right - we've got to keep the `incipit` field around, because some chants have only an incipit and no full text. So it seems that we need to automatically update the `incipit` field, almost certainly with a hook/signal whenever a chant/sequence is created/modified.

We'll only ever want to use the Standardized Spelling fulltext, correct @annamorphism, and never the MS Spelling fulltext?

Resolving patch:
diff --git a/django/cantusdb_project/main_app/admin.py b/django/cantusdb_project/main_app/admin.py
--- a/django/cantusdb_project/main_app/admin.py
+++ b/django/cantusdb_project/main_app/admin.py
@@ -21,6 +21,8 @@
 READ_ONLY = (
     "created_by",
     "last_updated_by",
+    "date_created",
+    "date_updated",
 )
 
 
@@ -61,10 +63,7 @@ def get_source_siglum(self, obj):
         "id",
     )
 
-    readonly_fields = READ_ONLY + (
-        "date_created",
-        "date_updated",
-    )
+    readonly_fields = READ_ONLY + ("incipit",)
 
     list_filter = (
         "genre",
@@ -170,6 +169,7 @@ def get_source_siglum(self, obj):
         "source",
         "feast",
     )
+    readonly_fields = READ_ONLY + ("incipit",)
     ordering = ("source__siglum",)
     form = AdminSequenceForm
 
diff --git a/django/cantusdb_project/main_app/signals.py b/django/cantusdb_project/main_app/signals.py
--- a/django/cantusdb_project/main_app/signals.py
+++ b/django/cantusdb_project/main_app/signals.py
@@ -7,6 +7,8 @@
 from django.db.models.signals import post_save, post_delete
 from django.dispatch import receiver
 
+from typing import Optional
+
 import re
 
 from main_app.models import Chant
@@ -16,36 +18,38 @@
 
 
 @receiver(post_save, sender=Chant)
-def on_chant_save(instance, **kwargs):
+def on_chant_save(instance, **kwargs) -> None:
     update_source_chant_count(instance)
     update_source_melody_count(instance)
 
     update_chant_search_vector(instance)
+    update_chant_incipit_field(instance)
     update_volpiano_fields(instance)
 
 
 @receiver(post_delete, sender=Chant)
-def on_chant_delete(instance, **kwargs):
+def on_chant_delete(instance, **kwargs) -> None:
     update_source_chant_count(instance)
     update_source_melody_count(instance)
 
 
 @receiver(post_save, sender=Sequence)
-def on_sequence_save(instance, **kwargs):
+def on_sequence_save(instance, **kwargs) -> None:
     update_source_chant_count(instance)
+    update_sequence_incipit_field(instance)
 
 
 @receiver(post_delete, sender=Sequence)
-def on_sequence_delete(instance, **kwargs):
+def on_sequence_delete(instance, **kwargs) -> None:
     update_source_chant_count(instance)
 
 
 @receiver(post_save, sender=Feast)
-def on_feast_save(instance, **kwargs):
+def on_feast_save(instance, **kwargs) -> None:
     update_prefix_field(instance)
 
 
-def update_chant_search_vector(instance):
+def update_chant_search_vector(instance) -> None:
     """When saving an instance of Chant, update its search vector field.
 
     Called in on_chant_save()
@@ -63,7 +67,7 @@ def update_chant_search_vector(instance):
     )
 
 
-def update_source_chant_count(instance):
+def update_source_chant_count(instance) -> None:
     """When saving or deleting a Chant or Sequence, update its Source's number_of_chants field
 
     Called in on_chant_save(), on_chant_delete(), on_sequence_save() and on_sequence_delete()
@@ -79,7 +83,7 @@ def update_source_chant_count(instance):
         source.save()
 
 
-def update_source_melody_count(instance):
+def update_source_melody_count(instance) -> None:
     """When saving or deleting a Chant, update its Source's number_of_melodies field
 
     Called in on_chant_save() and on_chant_delete()
@@ -99,88 +103,12 @@ def update_source_melody_count(instance):
         source.save()
 
 
-def update_volpiano_fields(instance):
+def update_volpiano_fields(instance) -> None:
     """When saving a Chant, make sure the chant's volpiano_notes and volpiano_intervals are up-to-date
 
     Called in on_chant_save()
     """
 
-    def generate_volpiano_notes(volpiano):
-        """
-        Populate the ``volpiano_notes`` field of the ``Chant`` model
-
-        This field is used for melody search
-
-        Args:
-            volpiano (str): The content of ``chant.volpiano``
-
-        Returns:
-            str: Volpiano str with non-note chars and duplicate consecutive notes removed
-        """
-        # unwanted_chars are non-note chars, including the clefs, barlines, and accidentals etc.
-        # the `searchMelody.js` on old cantus makes no reference to the b-flat accidentals ("y", "i", "z")
-        # so put them in unwanted chars for now
-        unwanted_chars = [
-            "-",
-            "1",
-            "2",
-            "3",
-            "4",
-            "5",
-            "6",
-            "7",
-            "?",
-            ".",
-            " ",
-            "y",
-            "i",
-            "z",
-        ]
-        # convert all charactors to lower-case, upper-case letters stand for liquescent of the same pitch
-        volpiano_lower = volpiano.lower()
-        # `)` stands for the lowest `g` note liquescent in volpiano, its 'lower case' is `9`
-        volpiano_notes = volpiano_lower.replace(")", "9")
-        # remove none-note charactors
-        for unwanted_char in unwanted_chars:
-            volpiano_notes = volpiano_notes.replace(unwanted_char, "")
-        # remove duplicate consecutive chars
-        volpiano_notes = re.sub(r"(.)\1+", r"\1", volpiano_notes)
-        return volpiano_notes
-
-    def generate_volpiano_intervals(volpiano_notes):
-        """
-        Populate the ``volpiano_intervals`` field of the ``Chant`` model
-
-        This field is used for melody search when searching for transpositions
-
-        Args:
-            volpiano_notes (str): The content of ``chant.volpiano_notes``,
-            populated by the ``generate_volpiano_notes`` function
-
-        Returns:
-            str: A str of digits, recording the intervals between adjacent notes
-        """
-        # replace '9' (the note G) with the char corresponding to (ASCII(a) - 1), because 'a' denotes the note A
-        volpiano_notes = volpiano_notes.replace("9", chr(ord("a") - 1))
-        # we model the interval between notes using the difference between the ASCII codes of corresponding letters
-        # the letter for the note B is "j" (106), note A is "h" (104), the letter "i" (105) is skipped
-        # move all notes above A down by one letter
-        volpiano_notes = list(volpiano_notes)
-        for j, note in enumerate(volpiano_notes):
-            if ord(note) >= 106:
-                volpiano_notes[j] = chr(ord(note) - 1)
-
-        # `intervals` records the difference between two adjacent notes.
-        # Note that intervals are encoded by counting the number of scale
-        # steps between adjacent notes: an ascending second is thus encoded
-        # as "1"; a descending third is encoded "-2", and so on.
-        intervals = []
-        for j in range(1, len(volpiano_notes)):
-            intervals.append(ord(volpiano_notes[j]) - ord(volpiano_notes[j - 1]))
-        # convert `intervals` to str
-        volpiano_intervals = "".join([str(interval) for interval in intervals])
-        return volpiano_intervals
-
     if instance.volpiano is None:
         return
 
@@ -193,7 +121,84 @@ def generate_volpiano_intervals(volpiano_notes):
     )
 
 
-def update_prefix_field(instance):
+def generate_volpiano_notes(volpiano) -> str:
+    """
+    Populate the ``volpiano_notes`` field of the ``Chant`` model
+
+    This field is used for melody search
+
+    Args:
+        volpiano (str): The content of ``chant.volpiano``
+
+    Returns:
+        str: Volpiano str with non-note chars and duplicate consecutive notes removed
+    """
+    # unwanted_chars are non-note chars, including the clefs, barlines, and accidentals etc.
+    # the `searchMelody.js` on old cantus makes no reference to the b-flat accidentals ("y", "i", "z")
+    # so put them in unwanted chars for now
+    unwanted_chars: list[str] = [
+        "-",
+        "1",
+        "2",
+        "3",
+        "4",
+        "5",
+        "6",
+        "7",
+        "?",
+        ".",
+        " ",
+        "y",
+        "i",
+        "z",
+    ]
+    # convert all charactors to lower-case, upper-case letters stand for liquescent of the same pitch
+    volpiano_lower: str = volpiano.lower()
+    # `)` stands for the lowest `g` note liquescent in volpiano, its 'lower case' is `9`
+    volpiano_notes: str = volpiano_lower.replace(")", "9")
+    # remove none-note charactors
+    for unwanted_char in unwanted_chars:
+        volpiano_notes = volpiano_notes.replace(unwanted_char, "")
+    # remove duplicate consecutive chars
+    volpiano_notes = re.sub(r"(.)\1+", r"\1", volpiano_notes)
+    return volpiano_notes
+
+
+def generate_volpiano_intervals(volpiano_notes) -> str:
+    """
+    Populate the ``volpiano_intervals`` field of the ``Chant`` model
+
+    This field is used for melody search when searching for transpositions
+
+    Args:
+        volpiano_notes (str): The content of ``chant.volpiano_notes``,
+        populated by the ``generate_volpiano_notes`` function
+
+    Returns:
+        str: A str of digits, recording the intervals between adjacent notes
+    """
+    # replace '9' (the note G) with the char corresponding to (ASCII(a) - 1), because 'a' denotes the note A
+    volpiano_notes: str = volpiano_notes.replace("9", chr(ord("a") - 1))
+    # we model the interval between notes using the difference between the ASCII codes of corresponding letters
+    # the letter for the note B is "j" (106), note A is "h" (104), the letter "i" (105) is skipped
+    # move all notes above A down by one letter
+    notes_list: list = list(volpiano_notes)
+    for j, note in enumerate(notes_list):
+        if ord(note) >= 106:
+            notes_list[j] = chr(ord(note) - 1)
+
+    # `intervals` records the difference between two adjacent notes.
+    # Note that intervals are encoded by counting the number of scale
+    # steps between adjacent notes: an ascending second is thus encoded
+    # as "1"; a descending third is encoded "-2", and so on.
+    intervals: list[int] = []
+    for j in range(1, len(notes_list)):
+        intervals.append(ord(notes_list[j]) - ord(notes_list[j - 1]))
+    volpiano_intervals: str = "".join([str(interval) for interval in intervals])
+    return volpiano_intervals
+
+
+def update_prefix_field(instance) -> None:
     pk = instance.pk
 
     if instance.feast_code:
@@ -201,3 +206,53 @@ def update_prefix_field(instance):
         instance.__class__.objects.filter(pk=pk).update(prefix=prefix)
     else:  # feast_code is None, ""
         instance.__class__.objects.filter(pk=pk).update(prefix="")
+
+
+def update_chant_incipit_field(chant: Chant) -> None:
+    """Update the incipit field of the specified Chant to be the first
+    several words of the chant's standardized-spelling fulltext
+
+    Args:
+        chant (Chant): The chant from the database whose `incipit` field
+        is to be updated
+    """
+    fulltext: Optional[str] = chant.manuscript_full_text_std_spelling
+    if fulltext:  # many chants in the database have only an incipit -
+        # we should only update the incipit if the chant has a fulltext,
+        # just in case a chant manages to get saved without a fulltext somehow
+        new_incipit: str = generate_incipit(fulltext)
+        Chant.objects.filter(id=chant.id).update(incipit=new_incipit)
+
+
+def update_sequence_incipit_field(sequence: Sequence) -> None:
+    """Update the incipit field of the specified Sequence to be the first
+    several words of the sequence's standardized-spelling fulltext
+
+    Args:
+        sequence (Sequence): The sequence from the database whose `incipit`
+        field is to be updated
+    """
+    title: Optional[str] = sequence.title
+    if title:  # As of late Feb 2024, no sequences in the database have
+        # fulltext, but every sequence has a title, and the value stored in
+        # the title field is an incipit.
+        incipit: str = title
+        Sequence.objects.filter(id=sequence.id).update(incipit=incipit)
+
+
+def generate_incipit(fulltext: str) -> str:
+    """Given the fulltext of a chant or sequence, generate an incipit
+    consisting of the first 5 words of the fulltext.
+
+    Args:
+        fulltext (str): the full text of a chant or sequence
+
+    Returns:
+        str: an incipit - the first five words of the fulltext
+    """
+    INCIPIT_LENGTH: int = 5  # number of words to include in the new incipit
+
+    fulltext_words: list[str] = fulltext.split(" ")
+    incipit_words: list[str] = fulltext_words[:INCIPIT_LENGTH]
+    incipit: str = " ".join(incipit_words)
+    return incipit


Test patch:
diff --git a/django/cantusdb_project/main_app/tests/make_fakes.py b/django/cantusdb_project/main_app/tests/make_fakes.py
--- a/django/cantusdb_project/main_app/tests/make_fakes.py
+++ b/django/cantusdb_project/main_app/tests/make_fakes.py
@@ -17,12 +17,11 @@
 
 from typing import Optional, List
 
+User = get_user_model()
+
 # Max positive integer accepted by Django's positive integer field
 MAX_SEQUENCE_NUMBER = 2147483647
 
-# The incipit will be up to 100 characters of the full text
-INCIPIT_LENGTH = 100
-
 # Create a Faker instance with locale set to Latin
 faker = Faker("la")
 
@@ -146,7 +145,6 @@ def make_fake_chant(
     feast=None,
     mode=None,
     manuscript_full_text_std_spelling=None,
-    incipit=None,
     manuscript_full_text_std_proofread=None,
     manuscript_full_text=None,
     volpiano=None,
@@ -180,8 +178,6 @@ def make_fake_chant(
         mode = make_random_string(1, "0123456789*?")
     if manuscript_full_text_std_spelling is None:
         manuscript_full_text_std_spelling = faker.sentence()
-    if incipit is None:
-        incipit = manuscript_full_text_std_spelling[0:INCIPIT_LENGTH]
     if manuscript_full_text_std_proofread is None:
         manuscript_full_text_std_proofread = False
     if manuscript_full_text is None:
@@ -214,7 +210,6 @@ def make_fake_chant(
         ),  # chant_range is of the form "1-x-y-4", x, y are volpiano notes
         addendum=make_random_string(3, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
         manuscript_full_text_std_spelling=manuscript_full_text_std_spelling,
-        incipit=incipit,
         manuscript_full_text_std_proofread=manuscript_full_text_std_proofread,
         manuscript_full_text=manuscript_full_text,
         manuscript_full_text_proofread=faker.boolean(),
@@ -228,6 +223,9 @@ def make_fake_chant(
         json_info=None,
         next_chant=next_chant,
     )
+    chant.refresh_from_db()  # several fields (e.g., incipit) are calculated automatically
+    # upon chant save. By refreshing from db before returning, we ensure all the chant's fields
+    # are up-to-date. For more information, refer to main_app/signals.py
     return chant
 
 
@@ -256,9 +254,9 @@ def make_fake_genre(name=None) -> Genre:
     return genre
 
 
-def make_fake_user(is_indexer=True) -> get_user_model():
+def make_fake_user(is_indexer=True) -> User:
     """Generates a fake User object."""
-    user = get_user_model().objects.create(
+    user = User.objects.create(
         full_name=f"{faker.first_name()} {faker.last_name()}",
         institution=faker.company(),
         city=faker.city(),
@@ -310,22 +308,17 @@ def make_fake_segment(name: str = None, id: int = None) -> Segment:
     return segment
 
 
-def make_fake_sequence(
-    source=None, title=None, incipit=None, cantus_id=None
-) -> Sequence:
+def make_fake_sequence(source=None, title=None, cantus_id=None) -> Sequence:
     """Generates a fake Sequence object."""
     if source is None:
         source = make_fake_source(segment_name="Bower Sequence Database")
     if title is None:
         title = faker.sentence()
-    if incipit is None:
-        incipit = title[:INCIPIT_LENGTH]
     if cantus_id is None:
         cantus_id = make_random_string(6, "0123456789")
     sequence = Sequence.objects.create(
         title=title,
         siglum=make_random_string(6),
-        incipit=incipit,
         # folio in the form of two digits and one letter
         folio=faker.bothify("##?"),
         s_sequence=make_random_string(2, "0123456789"),
@@ -339,6 +332,9 @@ def make_fake_sequence(
         cantus_id=cantus_id,
         image_link=faker.image_url(),
     )
+    sequence.refresh_from_db()  # several fields (e.g., incipit) are calculated automatically
+    # upon sequence save. By refreshing from db before returning, we ensure all the sequence's fields
+    # are up-to-date. For more information, refer to main_app/signals.py
     return sequence
 
 
diff --git a/django/cantusdb_project/main_app/tests/test_functions.py b/django/cantusdb_project/main_app/tests/test_functions.py
--- a/django/cantusdb_project/main_app/tests/test_functions.py
+++ b/django/cantusdb_project/main_app/tests/test_functions.py
@@ -9,6 +9,7 @@
     make_fake_source,
 )
 from main_app.management.commands import update_cached_concordances
+from main_app.signals import generate_incipit
 
 # run with `python -Wa manage.py test main_app.tests.test_functions`
 # the -Wa flag tells Python to display deprecation warnings
@@ -65,7 +66,6 @@ def test_published_vs_unpublished(self):
 
         concordances: list = update_cached_concordances.get_concordances()
         self.assertEqual(len(concordances), 1)
-
         single_concordance: dict = concordances[0]
         expected_fulltext: str = published_chant.manuscript_full_text_std_spelling
         observed_fulltext: str = single_concordance["full_text"]
@@ -100,3 +100,20 @@ def test_concordances_values(self):
             observed_value: Union[str, int, None] = single_concordance[key]
             with self.subTest(key=key):
                 self.assertEqual(observed_value, value)
+
+
+class IncipitSignalTest(TestCase):
+    # testing an edge case in generate_incipit, within main_app/signals.py.
+    # Some other tests involving this function can be found
+    # in ChantModelTest and SequenceModelTest.
+    def test_generate_incipit(self):
+        complete_fulltext: str = "one two three four five six seven"
+        expected_incipit_1: str = "one two three four five"
+        observed_incipit_1: str = generate_incipit(complete_fulltext)
+        with self.subTest(test="full-length fulltext"):
+            self.assertEqual(observed_incipit_1, expected_incipit_1)
+        short_fulltext: str = "one*"
+        expected_incipit_2 = "one*"
+        observed_incipit_2 = generate_incipit(short_fulltext)
+        with self.subTest(test="fulltext that's already a short incipit"):
+            self.assertEqual(observed_incipit_2, expected_incipit_2)
diff --git a/django/cantusdb_project/main_app/tests/test_models.py b/django/cantusdb_project/main_app/tests/test_models.py
--- a/django/cantusdb_project/main_app/tests/test_models.py
+++ b/django/cantusdb_project/main_app/tests/test_models.py
@@ -231,6 +231,17 @@ def test_get_next_chant__lacuna(self):
         self.assertEqual(chant1.get_next_chant(), lacuna)
         self.assertEqual(lacuna.get_next_chant(), chant3)
 
+    def test_incipit_signal(self):
+        """Test whether a chant's incipit is updated to reflect its fulltext upon save"""
+        chant: Chant = make_fake_chant()
+        full_text: str = "Incipit should be five words sheep headphones bongoes"
+        expected_incipit: str = "Incipit should be five words"
+        chant.manuscript_full_text_std_spelling = full_text
+        chant.save()
+        chant.refresh_from_db()
+        observed_incipit: str = chant.incipit
+        self.assertEqual(observed_incipit, expected_incipit)
+
 
 class FeastModelTest(TestCase):
     @classmethod
@@ -349,6 +360,17 @@ def test_chant_and_sequence_have_same_fields(self):
         seq_fields = Sequence.get_fields_and_properties()
         self.assertEqual(chant_fields, seq_fields)
 
+    def test_incipit_signal(self):
+        """Test whether a sequence's incipit is updated to reflect its title upon save"""
+        sequence: Sequence = make_fake_sequence()
+        title: str = "Incipit titulus esse debet"
+        expected_incipit: str = title
+        sequence.title = title
+        sequence.save()
+        sequence.refresh_from_db()
+        observed_incipit: str = sequence.incipit
+        self.assertEqual(observed_incipit, expected_incipit)
+
 
 class SourceModelTest(TestCase):
     @classmethod
diff --git a/django/cantusdb_project/main_app/tests/test_views.py b/django/cantusdb_project/main_app/tests/test_views.py
--- a/django/cantusdb_project/main_app/tests/test_views.py
+++ b/django/cantusdb_project/main_app/tests/test_views.py
@@ -827,7 +827,6 @@ def test_chant_with_volpiano_with_no_fulltext(self):
         chant = make_fake_chant(
             source=source,
             volpiano="1---c--g--e---e---d---c---c---f---e---e--d---d---c",
-            incipit="somebody",
         )
         chant.manuscript_full_text = None
         chant.manuscript_full_text_std_spelling = None
@@ -1083,9 +1082,13 @@ def test_search_bar_search(self):
 
     def test_order_by_siglum(self):
         source_1 = make_fake_source(published=True, siglum="sigl-1")
-        chant_1 = make_fake_chant(incipit="thing 1", source=source_1)
+        chant_1 = make_fake_chant(
+            manuscript_full_text_std_spelling="thing 1", source=source_1
+        )
         source_2 = make_fake_source(published=True, siglum="sigl-2")
-        chant_2 = make_fake_chant(incipit="thing 2", source=source_2)
+        chant_2 = make_fake_chant(
+            manuscript_full_text_std_spelling="thing 2", source=source_2
+        )
 
         search_term = "thing"
 
@@ -1121,8 +1124,12 @@ def test_order_by_siglum(self):
 
     def test_order_by_incipit(self):
         source = make_fake_source(published=True)
-        chant_1 = make_fake_chant(source=source, incipit="higgledy")
-        chant_2 = make_fake_chant(source=source, incipit="piggledy")
+        chant_1 = make_fake_chant(
+            source=source, manuscript_full_text_std_spelling="higgledy"
+        )
+        chant_2 = make_fake_chant(
+            source=source, manuscript_full_text_std_spelling="piggledy"
+        )
 
         search_term = "iggl"
 
@@ -1161,8 +1168,12 @@ def test_order_by_office(self):
         office_1 = make_fake_office()
         office_2 = make_fake_office()
         assert office_1.id < office_2.id
-        chant_1 = make_fake_chant(office=office_1, incipit="hocus")
-        chant_2 = make_fake_chant(office=office_2, incipit="pocus")
+        chant_1 = make_fake_chant(
+            office=office_1, manuscript_full_text_std_spelling="hocus"
+        )
+        chant_2 = make_fake_chant(
+            office=office_2, manuscript_full_text_std_spelling="pocus"
+        )
 
         search_term = "ocu"
 
@@ -1201,8 +1212,12 @@ def test_order_by_genre(self):
         genre_1 = make_fake_genre()
         genre_2 = make_fake_genre()
         assert genre_1.id < genre_2.id
-        chant_1 = make_fake_chant(genre=genre_1, incipit="hocus")
-        chant_2 = make_fake_chant(genre=genre_2, incipit="pocus")
+        chant_1 = make_fake_chant(
+            genre=genre_1, manuscript_full_text_std_spelling="hocus"
+        )
+        chant_2 = make_fake_chant(
+            genre=genre_2, manuscript_full_text_std_spelling="focus"
+        )
 
         search_term = "ocu"
 
@@ -1237,8 +1252,12 @@ def test_order_by_genre(self):
         self.assertEqual(last_result_incipit, chant_1.incipit)
 
     def test_order_by_cantus_id(self):
-        chant_1 = make_fake_chant(incipit="isaac", cantus_id="121393")
-        chant_2 = make_fake_chant(incipit="baal", cantus_id="196418")
+        chant_1 = make_fake_chant(
+            manuscript_full_text_std_spelling="isaac", cantus_id="121393"
+        )
+        chant_2 = make_fake_chant(
+            manuscript_full_text_std_spelling="baal", cantus_id="196418"
+        )
 
         search_term = "aa"
 
@@ -1274,11 +1293,11 @@ def test_order_by_cantus_id(self):
 
     def test_order_by_mode(self):
         chant_1 = make_fake_chant(
-            incipit="For first he looks upon his forepaws to see if they are clean",
+            manuscript_full_text_std_spelling="For first he looks upon his forepaws to see if they are clean",
             mode="1",
         )
         chant_2 = make_fake_chant(
-            incipit="For secondly he kicks up behind to clear away there",
+            manuscript_full_text_std_spelling="For secondly he kicks up behind to clear away there",
             mode="2",
         )
 
@@ -1316,16 +1335,16 @@ def test_order_by_mode(self):
 
     def test_order_by_ms_fulltext(self):
         chant_1 = make_fake_chant(
-            incipit="this is a chant with a MS spelling fulltext",
             manuscript_full_text="this is a chant with a MS spelling fylltexte",
+            manuscript_full_text_std_spelling="this is a chant with a MS spelling fulltext",
         )
         chant_2 = make_fake_chant(
-            incipit="this is a chant without",
+            manuscript_full_text_std_spelling="this will become a chant without a MS spelling fulltext",
         )
         chant_2.manuscript_full_text = None
         chant_2.save()
 
-        search_term = "s is a ch"
+        search_term = "a chant wit"
 
         response_ascending = self.client.get(
             reverse("chant-search"),
@@ -1359,11 +1378,11 @@ def test_order_by_ms_fulltext(self):
 
     def test_order_by_volpiano(self):
         chant_1 = make_fake_chant(
-            incipit="this is a chant with volpiano",
+            manuscript_full_text_std_spelling="this is a chant with volpiano",
             volpiano="1---d---d---a--a---a---e--f--e---d---4",
         )
         chant_2 = make_fake_chant(
-            incipit="this is a chant about parsley",
+            manuscript_full_text_std_spelling="this is a chant about parsley",
         )
         chant_2.volpiano = None
         chant_2.save()
@@ -1402,11 +1421,11 @@ def test_order_by_volpiano(self):
 
     def test_order_by_image_link(self):
         chant_1 = make_fake_chant(
-            incipit="this is a chant with a link",
+            manuscript_full_text_std_spelling="this is a chant with a link",
             image_link="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
         )
         chant_2 = make_fake_chant(
-            incipit="this is a chant without",
+            manuscript_full_text_std_spelling="this is a chant without",
         )
         chant_2.image_link = None
         chant_2.save()
@@ -1446,12 +1465,11 @@ def test_order_by_image_link(self):
     def test_column_header_links(self):
         # these are the 9 column headers users can order by:
         siglum = "glum-01"
-        incipit = "so it begins"
+        fulltext = "so it begins"
         office = make_fake_office()
         genre = make_fake_genre()
         cantus_id = make_random_string(6, "0123456789")
         mode = make_random_string(1, "0123456789*?")
-        ms_ft = faker.sentence()
         mel = make_fake_volpiano()
         image = faker.image_url()
 
@@ -1461,12 +1479,11 @@ def test_column_header_links(self):
         feast = make_fake_feast()
         position = make_random_string(1)
         chant = make_fake_chant(
-            incipit=incipit,
+            manuscript_full_text_std_spelling=fulltext,
             office=office,
             genre=genre,
             cantus_id=cantus_id,
             mode=mode,
-            manuscript_full_text_std_spelling=ms_ft,
             volpiano=mel,
             image_link=image,
             source=source,
@@ -1968,30 +1985,36 @@ def test_keyword_search_searching_all_fields(self):
         includes_search_term = "brevity is the soul of wit"
         doesnt_include_search_term = "longevity is the soul of wit"
         source = make_fake_source()
-        chant_incipit = make_fake_chant(
-            source=source,
-            incipit=includes_search_term,  # <==
-            manuscript_full_text=doesnt_include_search_term,
-            manuscript_full_text_std_spelling=doesnt_include_search_term,
-        )
+
         chant_ms_spelling = make_fake_chant(
             source=source,
-            incipit=doesnt_include_search_term,
-            manuscript_full_text=includes_search_term,  # <==
+            manuscript_full_text=includes_search_term,  # <== includes_search_term
             manuscript_full_text_std_spelling=doesnt_include_search_term,
         )
+
         chant_std_spelling = make_fake_chant(
             source=source,
-            incipit=doesnt_include_search_term,
             manuscript_full_text=doesnt_include_search_term,
             manuscript_full_text_std_spelling=includes_search_term,  # <==
         )
+
+        # some chants inherited from OldCantus have an incipit but no full-text -
+        # we need to ensure these chants appear in the results
+        chant_incipit = make_fake_chant(
+            source=source,
+        )
+        Chant.objects.filter(id=chant_incipit.id).update(
+            incipit=includes_search_term,  # <==
+            manuscript_full_text=None,
+            manuscript_full_text_std_spelling=None,
+        )
+
         chant_without_search_term = make_fake_chant(
             source=source,
-            incipit=doesnt_include_search_term,
             manuscript_full_text=doesnt_include_search_term,
             manuscript_full_text_std_spelling=doesnt_include_search_term,
         )
+
         response_starts_with = self.client.get(
             reverse("chant-search-ms", args=[source.id]),
             {"keyword": search_term, "op": "starts_with"},
@@ -2005,8 +2028,12 @@ def test_keyword_search_searching_all_fields(self):
 
     def test_order_by_incipit(self):
         source = make_fake_source(published=True)
-        chant_1 = make_fake_chant(source=source, incipit="higgledy")
-        chant_2 = make_fake_chant(source=source, incipit="piggledy")
+        chant_1 = make_fake_chant(
+            source=source, manuscript_full_text_std_spelling="higgledy"
+        )
+        chant_2 = make_fake_chant(
+            source=source, manuscript_full_text_std_spelling="piggledy"
+        )
 
         search_term = "iggl"
 
@@ -2046,8 +2073,12 @@ def test_order_by_office(self):
         office_1 = make_fake_office()
         office_2 = make_fake_office()
         assert office_1.id < office_2.id
-        chant_1 = make_fake_chant(office=office_1, incipit="hocus", source=source)
-        chant_2 = make_fake_chant(office=office_2, incipit="pocus", source=source)
+        chant_1 = make_fake_chant(
+            office=office_1, manuscript_full_text_std_spelling="hocus", source=source
+        )
+        chant_2 = make_fake_chant(
+            office=office_2, manuscript_full_text_std_spelling="pocus", source=source
+        )
 
         search_term = "ocu"
 
@@ -2087,8 +2118,12 @@ def test_order_by_genre(self):
         genre_1 = make_fake_genre()
         genre_2 = make_fake_genre()
         assert genre_1.id < genre_2.id
-        chant_1 = make_fake_chant(genre=genre_1, incipit="hocus", source=source)
-        chant_2 = make_fake_chant(genre=genre_2, incipit="pocus", source=source)
+        chant_1 = make_fake_chant(
+            genre=genre_1, manuscript_full_text_std_spelling="hocus", source=source
+        )
+        chant_2 = make_fake_chant(
+            genre=genre_2, manuscript_full_text_std_spelling="pocus", source=source
+        )
 
         search_term = "ocu"
 
@@ -2124,8 +2159,12 @@ def test_order_by_genre(self):
 
     def test_order_by_cantus_id(self):
         source = make_fake_source()
-        chant_1 = make_fake_chant(incipit="isaac", cantus_id="121393", source=source)
-        chant_2 = make_fake_chant(incipit="baal", cantus_id="196418", source=source)
+        chant_1 = make_fake_chant(
+            manuscript_full_text_std_spelling="isaac", cantus_id="121393", source=source
+        )
+        chant_2 = make_fake_chant(
+            manuscript_full_text_std_spelling="baal", cantus_id="196418", source=source
+        )
 
         search_term = "aa"
 
@@ -2162,12 +2201,12 @@ def test_order_by_cantus_id(self):
     def test_order_by_mode(self):
         source = make_fake_source()
         chant_1 = make_fake_chant(
-            incipit="For thirdly he works it upon stretch with the forepaws extended",
+            manuscript_full_text_std_spelling="For thirdly he works it upon stretch with the forepaws extended",
             mode="1",
             source=source,
         )
         chant_2 = make_fake_chant(
-            incipit="For fourthly he sharpens his paws by wood",
+            manuscript_full_text_std_spelling="For fourthly he sharpens his paws by wood",
             mode="2",
             source=source,
         )
@@ -2207,12 +2246,12 @@ def test_order_by_mode(self):
     def test_order_by_ms_fulltext(self):
         source = make_fake_source()
         chant_1 = make_fake_chant(
-            incipit="this is a chant with a MS spelling fulltext",
+            manuscript_full_text_std_spelling="this is a chant with a MS spelling fulltext",
             manuscript_full_text="this is a chant with a MS spelling fylltexte",
             source=source,
         )
         chant_2 = make_fake_chant(
-            incipit="this is a chant without",
+            manuscript_full_text_std_spelling="this is a chant without",
             source=source,
         )
         chant_2.manuscript_full_text = None
@@ -2254,12 +2293,12 @@ def test_order_by_volpiano(self):
         source = make_fake_source()
         chant_1 = make_fake_chant(
             source=source,
-            incipit="this is a chant with volpiano",
+            manuscript_full_text_std_spelling="this is a chant with volpiano",
             volpiano="1---d---d---a--a---a---e--f--e---d---4",
         )
         chant_2 = make_fake_chant(
             source=source,
-            incipit="this is a chant about parsley",
+            manuscript_full_text_std_spelling="this is a chant about parsley",
         )
         chant_2.volpiano = None
         chant_2.save()
@@ -2300,12 +2339,12 @@ def test_order_by_image_link(self):
         source = make_fake_source()
         chant_1 = make_fake_chant(
             source=source,
-            incipit="this is a chant with a link",
+            manuscript_full_text_std_spelling="this is a chant with a link",
             image_link="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
         )
         chant_2 = make_fake_chant(
             source=source,
-            incipit="this is a chant without",
+            manuscript_full_text_std_spelling="this is a chant without",
         )
         chant_2.image_link = None
         chant_2.save()
@@ -2345,12 +2384,12 @@ def test_order_by_image_link(self):
     def test_column_header_links(self):
         # these are the 9 column headers users can order by:
         siglum = "glum-01"
-        incipit = "so it begins"
+        full_text = "this is a full text that begins with the search term"
+        search_term = "this is a fu"
         office = make_fake_office()
         genre = make_fake_genre()
         cantus_id = make_random_string(6, "0123456789")
         mode = make_random_string(1, "0123456789*?")
-        ms_ft = faker.sentence()
         mel = make_fake_volpiano()
         image = faker.image_url()
 
@@ -2360,20 +2399,17 @@ def test_column_header_links(self):
         feast = make_fake_feast()
         position = make_random_string(1)
         chant = make_fake_chant(
-            incipit=incipit,
             office=office,
             genre=genre,
             cantus_id=cantus_id,
             mode=mode,
-            manuscript_full_text_std_spelling=ms_ft,
+            manuscript_full_text_std_spelling=full_text,
             volpiano=mel,
             image_link=image,
             source=source,
             feast=feast,
             position=position,
         )
-        search_term = "so it be"
-
         response_1 = self.client.get(
             reverse("chant-search-ms", args=[source.id]),
             {
@@ -3012,6 +3048,8 @@ def test_volpiano_signal(self):
             folio="001r",
             c_sequence=2,
         )
+        expected_volpiano: str = "abacadaeafagahaja"
+        expected_intervals: str = "1-12-23-34-45-56-67-78-8"
         self.client.post(
             reverse("source-edit-chants", args=[source.id]),
             {
@@ -3022,15 +3060,13 @@ def test_volpiano_signal(self):
             },
         )
         chant_2 = Chant.objects.get(manuscript_full_text_std_spelling="resonare foobaz")
-        self.assertEqual(chant_2.volpiano, "abacadaeafagahaja")
-        self.assertEqual(chant_2.volpiano_intervals, "1-12-23-34-45-56-67-78-8")
+        self.assertEqual(chant_2.volpiano, expected_volpiano)
+        self.assertEqual(chant_2.volpiano_intervals, expected_intervals)
 
     def test_chant_with_volpiano_with_no_fulltext(self):
         # in the past, a Chant Edit page will error rather than loading properly when the chant has volpiano but no fulltext
         source = make_fake_source()
-        chant = make_fake_chant(
-            source=source, volpiano="1---f--e---f--d---e--c---d--d", incipit="dies irae"
-        )
+        chant = make_fake_chant(source=source, volpiano="1---f--e---f--d---e--c---d--d")
         chant.manuscript_full_text = None
         chant.manuscript_full_text_std_spelling = None
         chant.save()
@@ -3114,7 +3150,7 @@ def test_view_url_and_templates(self):
 
     def test_edit_syllabification(self):
         chant = make_fake_chant(manuscript_syllabized_full_text="lorem ipsum")
-        self.assertIs(chant.manuscript_syllabized_full_text, "lorem ipsum")
+        self.assertEqual(chant.manuscript_syllabized_full_text, "lorem ipsum")
         response = self.client.post(
             f"/edit-syllabification/{chant.id}",
             {"manuscript_syllabized_full_text": "lo-rem ip-sum"},
@@ -3740,7 +3776,7 @@ def test_url_and_templates(self):
         self.assertTemplateUsed(response, "404.html")
 
     def test_update_sequence(self):
-        sequence = make_fake_sequence(incipit="test_update_sequence")
+        sequence = make_fake_sequence()
         sequence_id = str(sequence.id)
         response = self.client.post(
             reverse("sequence-edit", args=[sequence_id]),
@@ -5068,6 +5104,7 @@ def test_structure(self):
 
     def test_values(self):
         chant = make_fake_chant(cantus_id="100000")
+
         expected_values = {
             "siglum": chant.source.siglum,
             "srclink": f"http://testserver/source/{chant.source.id}",
@@ -5459,7 +5496,7 @@ def test_content_overview_view_selected_model(self):
 
     def test_source_selected_model(self):
         source = make_fake_source(title="Test Source")
-        chant = make_fake_chant(incipit="Test Chant")
+        chant = make_fake_chant()
         response = self.client.get(reverse("content-overview"), {"model": "sources"})
         self.assertContains(response, f"<b>Sources</b>", html=True)
         self.assertContains(
@@ -5578,15 +5615,17 @@ def test_response(self):
 
     def test_incipit_search(self):
         unremarkable_chant = make_fake_chant(
-            incipit=(
-                "The incipit contains no "
+            manuscript_full_text_std_spelling=(
+                "The fulltext contains no "
                 "numbers no asterisks and no punctuation "
                 "and is thus completely normal"
             )
         )
-        chant_with_asterisk = make_fake_chant(incipit="few words*")
+        chant_with_asterisk = make_fake_chant(
+            manuscript_full_text_std_spelling="few words*"
+        )
 
-        istartswith_search_term = "the incipit"
+        istartswith_search_term = "the fulltext"
         istartswith_response = self.client.get(
             reverse("ajax-search-bar", args=[istartswith_search_term])
         )
@@ -5622,11 +5661,11 @@ def test_incipit_search(self):
     def test_cantus_id_search(self):
         chant_with_normal_cantus_id = make_fake_chant(
             cantus_id="012345",
-            incipit="This incipit contains no numerals",
+            manuscript_full_text_std_spelling="This fulltext contains no numerals",
         )
         chant_with_numerals_in_incipit = make_fake_chant(
             cantus_id="123456",
-            incipit="0 me! 0 my! This is unexpected!",
+            manuscript_full_text_std_spelling="0 me! 0 my! This is unexpected!",
         )
 
         # for search terms that contain numerals, we should only return