Repository: feder-observatory/stellarphot

Issue:
Notebook 02 problems
- [ ] Add visual indication that aperture and variable files need to be saved or autosave them
- [x] Add settings for file names
- [x] Bring back some way to save TIC information?
- [ ] Offer a choice of source selection (maybe)

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/364?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) Report
Attention: Patch coverage is `92.72727%` with `4 lines` in your changes missing coverage. Please review.
> Project coverage is 77.03%. Comparing base [(`25c1ec5`)](https://app.codecov.io/gh/feder-observatory/stellarphot/commit/25c1ec571861063cde3cea1aba82a5ff56e13d9e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) to head [(`daf9ccc`)](https://app.codecov.io/gh/feder-observatory/stellarphot/commit/daf9ccc0a1a24ec3becd394abe3def416e5ac916?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory).
> Report is 3 commits behind head on main.

| [Files](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/364?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) | Patch % | Lines |
|---|---|---|
| [stellarphot/gui\_tools/comparison\_functions.py](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/364?src=pr&el=tree&filepath=stellarphot%2Fgui_tools%2Fcomparison_functions.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory#diff-c3RlbGxhcnBob3QvZ3VpX3Rvb2xzL2NvbXBhcmlzb25fZnVuY3Rpb25zLnB5) | 87.09% | [4 Missing :warning: ](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/364?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #364      +/-   ##
==========================================
+ Coverage   76.48%   77.03%   +0.55%     
==========================================
  Files          27       27              
  Lines        3665     3718      +53     
==========================================
+ Hits         2803     2864      +61     
+ Misses        862      854       -8     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/364?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory).

mwcraig: @Tanner728 @JuanCab this is still a work in progress but any feedback you have based on what is here so far is welcome....

Patch:
diff --git a/.jp_app_launcher_stellarphot.yaml b/.jp_app_launcher_stellarphot.yaml
index 13797c8..8937515 100644
--- a/.jp_app_launcher_stellarphot.yaml
+++ b/.jp_app_launcher_stellarphot.yaml
@@ -13,3 +13,11 @@
   cwd: not_used
   type: notebook
   catalog: Stellarphot photometry
+
+- title: Comparison stars
+  description: Choose comparison stars
+  source: ../stellarphot/photometry/02-viewer-comparison-stars-template.ipynb
+  icon: ../stellarphot/stellarphot-logo.svg
+  cwd: not_used
+  type: notebook
+  catalog: Stellarphot photometry
diff --git a/stellarphot/gui_tools/comparison_functions.py b/stellarphot/gui_tools/comparison_functions.py
index 44fdd29..2a0f652 100644
--- a/stellarphot/gui_tools/comparison_functions.py
+++ b/stellarphot/gui_tools/comparison_functions.py
@@ -1,5 +1,4 @@
 import functools
-from pathlib import Path
 
 import ipywidgets as ipw
 import numpy as np
@@ -16,7 +15,13 @@ except ImportError:
 from stellarphot import SourceListData
 from stellarphot.gui_tools.fits_opener import FitsOpener
 from stellarphot.gui_tools.seeing_profile_functions import set_keybindings
-from stellarphot.io import TOI, TessSubmission, TessTargetFile
+from stellarphot.settings import (
+    PartialPhotometrySettings,
+    PhotometryWorkingDirSettings,
+    SourceLocationSettings,
+    ui_generator,
+)
+from stellarphot.settings.custom_widgets import SettingWithTitle
 from stellarphot.utils.comparison_utils import (
     crossmatch_APASS2VSX,
     in_field,
@@ -196,11 +201,7 @@ class ComparisonViewer:
         Whether to overwrite existing output files. Defaults to True.
 
     observatory : `stellarphot.settings.Observatory`, optional
-        Observatory information. If this is set and the observatory has
-        a `stellarphot.settings.Observatory.TESS_telescope_code` attribute,
-        then the TESS submission information will be extracted from the header
-        and a button will appear to save images of the field for a TESS
-        submission.
+        Observatory information.
 
     Attributes
     ----------
@@ -232,9 +233,6 @@ class ComparisonViewer:
     target_mag : float
         Magnitude of the target.
 
-    tess_submission : `~stellarphot.io.TessSubmission`
-        Instance of the TESS submission class.
-
     variables : `astropy.table.Table`
     """
 
@@ -260,14 +258,17 @@ class ComparisonViewer:
         self.bright_mag_limit = bright_mag_limit
         self.dim_mag_limit = dim_mag_limit
         self.targets_from_file = targets_from_file
-        self.tess_submission = None
-        self._tess_object_info = None
         self.target_coord = object_coordinate
         self.observatory = observatory
 
         self.box, self.iw = self._viewer()
 
-        self.photom_apertures_file = photom_apertures_file
+        self.photometry_settings = PhotometryWorkingDirSettings()
+
+        if photom_apertures_file is not None:
+            # Set the source location file name to the name passed in
+            self._set_source_location_file_to_value(photom_apertures_file)
+
         self.overwrite_outputs = overwrite_outputs
 
         if file:
@@ -281,8 +282,6 @@ class ComparisonViewer:
         Handles aspects of initialization that need to be deferred until
         a file is chosen.
         """
-        if self.tess_submission is not None:
-            self._tess_object_info.layout.visibility = "visible"
         self.ccd, self.vsx = set_up(
             self._file_chooser.path.name,
             directory_with_images=self._file_chooser.path.parent,
@@ -311,6 +310,10 @@ class ComparisonViewer:
             name_or_coord=self.target_coord,
         )
 
+    @property
+    def photom_apertures_file(self):
+        return self.source_locations.value["source_list_file"]
+
     @property
     def variables(self):
         """
@@ -330,82 +333,45 @@ class ComparisonViewer:
         Try to automatically set object name immediately after file is chosen.
         """
         try:
-            self.object_name.value = self._file_chooser.header["object"]
+            self.object_name.value = (
+                f"<h2> Object: {self._file_chooser.header['object']}</h2>"
+            )
+            self._object = self._file_chooser.header["object"]
         except KeyError:
-            # No object, will show empty box for name
-            self.object_name.disabled = False
+            # No object, will show placeholder
+            self.object_name.value = "<h2>Object unknown</h2>"
+            self._object = ""
 
+        print(f"Object: {self._object}")
         # We have a name, try to get coordinates from it
         try:
-            self.target_coord = SkyCoord.from_name(self.object_name.value)
+            self.target_coord = SkyCoord.from_name(self._object)
         except NameResolveError:
-            pass
-
-        def _settings_for_no_tess():
-            # Guess not, time to turn on the coordinates box
-            # self._turn_on_coordinates()
-            self.tess_submission = None
-            self.toi_info = None
-            self.targets_from_file = None
-            self._tess_object_info.layout.visibility = "hidden"
-            self.tess_save_toggle.value = False
-            self.tess_save_toggle.disabled = True
-            self.tess_save_toggle.layout.visibility = "hidden"
-
-        # Maybe this is a tess object?
-
-        if self.observatory is None or self.observatory.TESS_telescope_code is None:
-            _settings_for_no_tess()
-            return
-
-        try:
-            self.tess_submission = TessSubmission.from_header(
-                self._file_chooser.header,
-                telescope_code=self.observatory.TESS_telescope_code,
-                planet=1,
-            )
-        except ValueError:
-            # Not a TESS object, so turn off the TESS interface
-            _settings_for_no_tess()
-        else:
-            # This is a TESS object, so set up the TESS interface
-            self.tess_save_toggle.disabled = False
-            self.tess_save_toggle.layout.visibility = "visible"
-            self.toi_info = TOI(self.tess_submission.tic_id)
-
-            self._target_file_info = TessTargetFile(
-                self.toi_info.coord, self.toi_info.tess_mag, self.toi_info.depth
+            # If there are no coordinations so far then use the center of the frame. The
+            # generation of  the source list table depends on the target coordinates.
+            self.target_coord = self.ccd.wcs.pixel_to_world(
+                self.ccd.shape[0] / 2, self.ccd.shape[1] / 2
             )
 
-            self.target_coord = self.tess_submission.tic_coord
-            self._tess_object_info.mag.value = self.toi_info.tess_mag
-            self._tess_object_info.depth.value = self.toi_info.depth
-            self._tess_object_info.layout.visibility = "visible"
-            self.targets_from_file = self._target_file_info.table
-
     def _set_file(self, change):  # noqa: ARG002
         """
         Widget callbacks need to accept a change argument, even if not used.
         """
-        self._set_object()
         self._init()
-        self._update_tess_save_names()
-
-    def _save_toggle_action(self, change):
-        activated = change["new"]
-
-        if activated:
-            self._tess_save_box.layout.visibility = "visible"
-        else:
-            self._tess_save_box.layout.visibility = "hidden"
+        self._set_object()
+        # Save the initial source list if there is a file name for it and if the target
+        # coordinates are known. The target coordinates are needed to generate the
+        # source list table.
+        if (
+            self.source_locations.value["source_list_file"] is not None
+            and self.target_coord is not None
+        ):
+            self._save_aperture_to_file(None)
 
     def _make_observers(self):
         self._show_labels_button.observe(self._show_label_button_handler, names="value")
         self._save_var_info.on_click(self._save_variables_to_file)
-        self._save_aperture_file.on_click(self._save_aperture_to_file)
         self._file_chooser.file_chooser.observe(self._set_file, names="_value")
-        self.tess_save_toggle.observe(self._save_toggle_action, "value")
-        self.save_files.on_click(self.save_tess_files)
 
     def _save_variables_to_file(self, button=None, filename=""):  # noqa: ARG002
         """
@@ -477,77 +443,55 @@ class ComparisonViewer:
 
         self._save_var_info = ipw.Button(description="Save variable info")
 
-        self._save_aperture_file = ipw.Button(description="Save aperture file")
-
         controls = ipw.HBox(
             children=[
                 self._show_labels_button,
                 self._save_var_info,
-                self._save_aperture_file,
             ]
         )
 
         return controls
 
-    def _make_tess_object_info(self):
+    def save(self):
         """
-        Make the controls for the TESS mag and depth used to look up GAIA target list.
+        Save all of the settings we have to a partial settings file.
         """
-        tess_object_info = ipw.HBox()
-        tess_mag = ipw.FloatText(description="TESS mag")
-        tess_depth = ipw.FloatText(description="Depth (ppt)")
-        tess_object_info.children = [tess_mag, tess_depth]
-        self._tess_object_info = tess_object_info
-        self._tess_object_info.mag = tess_mag
-        self._tess_object_info.depth = tess_depth
-        if self.tess_submission is None:
-            self._tess_object_info.layout.visibility = "hidden"
-
-    def _make_tess_save_box(self):
-        self.tess_save_toggle = ipw.ToggleButton(
-            description="TESS files...", disabled=True
-        )
-        self.tess_save_toggle.layout.visibility = "hidden"
-        self._tess_save_box = ipw.VBox()
-        self._tess_save_box.layout.visibility = "hidden"
-
-        tess_scope = self.observatory.TESS_telescope_code if self.observatory else None
-        tess_scope = "" if tess_scope is None else tess_scope
-        scope_name = ipw.Text(
-            description="Telescope code", value=tess_scope, style=DESC_STYLE
+        self.photometry_settings.save(
+            PartialPhotometrySettings(
+                source_locations=self.source_locations.value,
+            ),
+            update=True,
         )
 
-        planet_num = ipw.IntText(description="Planet", value=1)
-
-        dumb = []
-        dumb2 = []
-
-        for save in [
-            "Full field of view",
-            "Zoomed filed of view",
-        ]:  # , "Aperture file"]:
-            box = ipw.HBox()
-            title = ipw.HTML(value=f"<b>{save} file name</b>")
-            label = ipw.Label(value="")
-            box.children = (title, label)
-            dumb.append(label)
-            dumb2.append(box)
-
-        # self._field_name, self._zoom_name, self._aper_name = dumb
-        self._field_name, self._zoom_name = dumb
-        self.save_files = ipw.Button(description="Save")
-        self._tess_save_box.children = (
-            [scope_name, planet_num] + dumb2 + [self.save_files]
-        )
-
-    def _update_tess_save_names(self):
-        if self.tess_submission is not None:
-            self._field_name.value = self.tess_submission.field_image
-            self._zoom_name.value = self.tess_submission.field_image_zoom
-            # self._aper_name.value = self.tess_submission.apertures
-        else:
-            self._field_name.value = ""
-            self._zoom_name.value = ""
+        # For some reason the value of unsaved_changes is not updated until after this
+        # function executes, so we force its value here.
+        self.source_locations.savebuttonbar.unsaved_changes = False
+        # Update the save box title to reflect the save
+        self.source_and_title.decorate_title()
+
+    def _set_source_location_file_to_value(self, name=None):
+        # Right now the source location file name will not be set to the default name
+        # properly because of a bug in ipyautoui, so we set it manually here.
+        # Easier once/if https://github.com/maxfordham/ipyautoui/pull/323 is merged
+        # and released. Then we can just set the value of the widget directly like this:
+        #
+        # self.source_locations.value = {'source_list_file': name, ...rest of settings}
+        file_chooser = self.source_locations.di_widgets["source_list_file"]
+        if name is None:
+            # Use the default name
+            name = self.source_locations.model.model_fields["source_list_file"].default
+        # Make sure ipyautoui knows the value has changed
+        file_chooser.value = name
+
+        # Set the value to what we actually want
+        file_chooser.reset(".", name)
+        file_chooser._apply_selection()
+
+        # Because we have updated the value outside of ipyautoui, also force an update
+        # of the widget value.
+        current_value = self.source_locations.value.copy()
+        current_value["source_list_file"] = file_chooser.value
+        self.source_locations.value = current_value
 
     def _viewer(self):
         header = ipw.HTML(
@@ -560,10 +504,12 @@ class ComparisonViewer:
 
         legend = ipw.HTML(
             value="""
-        <h3>Green circles -- Gaia stars within 2.5 arcmin of target</h3>
-        <h3>Red circles -- APASS stars within 1 mag of target</h3>
-        <h3>Blue circles -- VSX variables</h3>
-        <h3>Red × -- Exclude as target or comp</h3>
+        <ul>
+        <li>Green circles -- Gaia stars within 2.5 arcmin of target</li>
+        <li>Red circles -- APASS stars within 1 mag of target</li>
+        <li>Blue circles -- VSX variables</li>
+        <li>Red × -- Exclude as target or comp</li>
+        </ul>
         """
         )
 
@@ -575,68 +521,40 @@ class ComparisonViewer:
         bind_map.map_event(None, ("shift",), "ms_left", "cursor")
         gvc.add_callback("cursor-down", wrap(iw, out))
 
-        self.object_name = ipw.Text(description="Object", disabled=True)
+        self.object_name = ipw.HTML(value="<h2>Object: </h2>")
+        self._object = None
         controls = self._make_control_bar()
-        self._make_tess_object_info()
-        self._make_tess_save_box()
+
+        self.source_locations = ui_generator(
+            SourceLocationSettings, max_field_width="75px"
+        )
+
+        self._set_source_location_file_to_value()
+        self.source_and_title = SettingWithTitle(
+            "Source location settings", self.source_locations
+        )
+
+        self.source_locations.savebuttonbar.fns_onsave_add_action(self.save)
+        self.help_stuff = ipw.Accordion(children=[header, legend])
+        self.help_stuff.titles = ["Help", "Legend"]
         box = ipw.VBox()
         inner_box = ipw.HBox()
-        inner_box.children = [iw, legend]
+        source_legend_box = ipw.VBox()
+        source_legend_box.children = [
+            self.object_name,
+            self.source_and_title,
+            self.help_stuff,
+        ]
+        inner_box.children = [iw, source_legend_box]  # legend]
+
         box.children = [
             self._file_chooser.file_chooser,
-            self.object_name,
-            self._tess_object_info,
-            header,
             inner_box,
             controls,
-            self.tess_save_toggle,
-            self._tess_save_box,
         ]
 
         return box, iw
 
-    def save_tess_files(self, button=None):  # noqa: ARG002
-        """
-        Save the TESS files.
-
-        Parameters
-        ----------
-
-        button : `ipywidgets.Button`, optional
-            The button that was clicked.
-
-        Returns
-        -------
-
-        None
-            Button to save TESS file set to true (triggering action).
-        """
-        if self._field_name.value:
-            self.tess_field_view()
-            # Remove output file if it exists
-            if Path(self._field_name.value).exists() and self.overwrite_outputs:
-                Path(self._field_name.value).unlink()
-            try:
-                self.iw.save(self._field_name.value)
-            except OSError as err:
-                raise OSError(
-                    f"Existing file ({self._field_name.value}) can not be overwritten. "
-                    "Set overwrite_outputs=True to address this."
-                ) from err
-
-        if self._zoom_name.value:
-            self.tess_field_zoom_view()
-            # Remove output file if it exists
-            if Path(self._zoom_name.value).exists() and self.overwrite_outputs:
-                Path(self._zoom_name.value).unlink()
-            try:
-                self.iw.save(self._zoom_name.value)
-            except OSError as err:
-                raise OSError(
-                    f"Existing file ({self._zoom_name.value}) can not be overwritten. "
-                    "Set overwrite_outputs=True to address this."
-                ) from err
-
     def generate_table(self):
         """
         Generate the table of stars to use for the aperture file.
diff --git a/stellarphot/settings/custom_widgets.py b/stellarphot/settings/custom_widgets.py
index 3e124de..a1afab8 100644
--- a/stellarphot/settings/custom_widgets.py
+++ b/stellarphot/settings/custom_widgets.py
@@ -628,3 +628,76 @@ class Confirm(ipw.HBox):
         if self._other_widget is not None:
             self._other_widget.layout.display = "flex"
         self.value = False
+
+
+class SettingWithTitle(ipw.VBox):
+    """
+    Class that adds a title to a setting widget made by ipyautoui and
+    styles the title based on whether the settings need to be saved.
+
+    Parameters
+    ----------
+
+    plain_title : str
+        Title of the setting widget without any decoration.
+
+    widget : ipyautoui.AutoUi
+        The setting widget to be displayed.
+    """
+
+    SETTING_NOT_SAVED = "❗️"
+    SETTING_IS_SAVED = "✅"
+
+    def __init__(self, plain_title, widget, header_level=2, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._header_level = header_level
+        self._plain_title = plain_title
+        self._widget = widget
+        self.title = ipw.HTML(value=self._format_title(plain_title))
+        self.children = [self.title, self._widget]
+        # Set up an observer to update title decoration when the settings
+        # change.
+        self._widget.observe(self.decorate_title, names="_value")
+        # Also update after the save button is clicked
+        self._widget.savebuttonbar.fns_onsave_add_action(self.decorate_title)
+
+    def _format_title(self, decorated_title):
+        return f"<h{self._header_level}>{decorated_title}</h{self._header_level}>"
+
+    def decorate_title(self, change=None):
+        """
+        Decorate the title based on whether the settings need to be saved.
+
+        Parameters
+        ----------
+
+        change : dict, optional
+            Change dictionary from a traitlets event. It is optional so that this
+            method can be called without a change dictionary.
+        """
+        # Keep track of settings state -- if dirty is true, the settings
+        # need to be saved.
+        dirty = False
+
+        # If we got here via a traitlets event then change is a dict with keys
+        # "new" and "old", check that case first. By checking explicitly for
+        # this case, we guarantee that we catch changes in value even if
+        # the button bar's unsaved_changes is still False.
+        try:
+            if change["new"] != change["old"]:
+                dirty = True
+        except (KeyError, TypeError):
+            dirty = False
+
+        # The unsaved_changes attribute is not a traitlet, and it isn't clear when
+        # in the event handling it gets set. When not called from an event, though,
+        # this function can only used unsaved_changes to decide what the title
+        # should be.
+        if self._widget.savebuttonbar.unsaved_changes or dirty:
+            self.title.value = self._format_title(
+                f"{self._plain_title} {self.SETTING_NOT_SAVED}"
+            )
+        else:
+            self.title.value = self._format_title(
+                f"{self._plain_title} {self.SETTING_IS_SAVED}"
+            )
diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py
index 8a70328..b11137c 100644
--- a/stellarphot/settings/models.py
+++ b/stellarphot/settings/models.py
@@ -618,7 +618,7 @@ class SourceLocationSettings(BaseModelWithTableRep):
                 filter_pattern=["*.ecsv", "*.csv"],
             )
         ),
-    ]
+    ] = "source_locations.ecsv"
     use_coordinates: Annotated[
         Literal["sky", "pixel"],
         Field(description="coordinates to use for locating sources in image"),


Test patch:
diff --git a/stellarphot/gui_tools/tests/test_comparison_functions.py b/stellarphot/gui_tools/tests/test_comparison_functions.py
index aa4b013..75c81cb 100644
--- a/stellarphot/gui_tools/tests/test_comparison_functions.py
+++ b/stellarphot/gui_tools/tests/test_comparison_functions.py
@@ -1,3 +1,4 @@
+import os
 import warnings
 
 import ipywidgets as ipw
@@ -11,6 +12,7 @@ from astropy.wcs import WCS
 from astropy.wcs.wcs import FITSFixedWarning
 
 from stellarphot.gui_tools import comparison_functions as cf
+from stellarphot.settings import PhotometryWorkingDirSettings
 
 CCD_SHAPE = [2048, 3073]
 
@@ -21,8 +23,10 @@ def test_comparison_object_creation():
     assert isinstance(comparison_widget.box, ipw.Box)
 
 
+@pytest.mark.parametrize("source_file_name", [None, "sources.ecsv"])
+@pytest.mark.parametrize("has_object", [True, False])
 @pytest.mark.remote_data
-def test_comparison_properties(tmp_path):
+def test_comparison_properties(tmp_path, has_object, source_file_name):
     # Test that we can load a file...
     wcs_file = get_pkg_data_filename("../../tests/data/sample_wcs_ey_uma.fits")
     with fits.open(wcs_file) as hdulist:
@@ -37,10 +41,16 @@ def test_comparison_properties(tmp_path):
             wcs = WCS(hdulist[0].header)
     wcs.pixel_shape = list(reversed(CCD_SHAPE))
     ccd = CCDData(data=np.zeros(CCD_SHAPE), wcs=wcs, unit="adu")
-    # Set the object name to the star in this field, EY UMa
-    ccd.header["object"] = "EY UMa"
+    if has_object:
+        # Set the object name to the star in this field, EY UMa
+        ccd.header["object"] = "EY UMa"
+
     file_name = "test.fits"
     ccd.write(tmp_path / file_name, overwrite=True)
+    # Change working directory for remainder of test so that the save does not pollute
+    # the testing directory.
+    os.chdir(tmp_path)
+
     with warnings.catch_warnings():
         # Ignore the warning about the WCS having non-standard keywords (the SIP
         # distortion parameters).
@@ -49,7 +59,11 @@ def test_comparison_properties(tmp_path):
             message="Some non-standard WCS keywords were excluded",
             category=AstropyWarning,
         )
-        comparison_widget = cf.ComparisonViewer(directory=tmp_path, file=str(file_name))
+        comparison_widget = cf.ComparisonViewer(
+            directory=tmp_path,
+            file=str(file_name),
+            photom_apertures_file=source_file_name,
+        )
 
     # Check that we have some variables in this field of view, which contains the
     # variable star EY UMa
@@ -59,7 +73,7 @@ def test_comparison_properties(tmp_path):
     table = comparison_widget.generate_table()
     assert "APASS comparison" in table["marker name"]
 
-    # Check that is we show labels then the label names we expect show up in
+    # Check that if we show labels then the label names we expect show up in
     # the astrowidgets marker table.
     comparison_widget.show_labels()
 
@@ -82,9 +96,23 @@ def test_comparison_properties(tmp_path):
 
     assert len(label_markers) == len(table)
 
+    # Make sure the aperture file has been written
+    assert os.path.exists(comparison_widget.source_locations.value["source_list_file"])
+    # Make sure the aperture file name matches the one we supplied, if
+    # we supplied one.
+    if source_file_name is not None:
+        assert (
+            comparison_widget.source_locations.value["source_list_file"]
+            == source_file_name
+        )
 
-def test_no_tess_buttons_at_init():
-    # Make sure TESS controls are not displayed initially
-    comparison_widget = cf.ComparisonViewer()
-    assert comparison_widget.tess_save_toggle.layout.visibility == "hidden"
-    assert comparison_widget._tess_object_info.layout.visibility == "hidden"
+    # Save the source list settings
+    comparison_widget.source_locations.savebuttonbar.bn_save.click()
+
+    # Check that a partial photometry settings file exists and that source locations
+    # settings have saved correctly.
+    partial_settings = PhotometryWorkingDirSettings().load()
+    assert (
+        partial_settings.source_locations.model_dump()
+        == comparison_widget.source_locations.value
+    )
diff --git a/stellarphot/settings/tests/test_custom_widgets.py b/stellarphot/settings/tests/test_custom_widgets.py
index 09f1bfe..97c16e3 100644
--- a/stellarphot/settings/tests/test_custom_widgets.py
+++ b/stellarphot/settings/tests/test_custom_widgets.py
@@ -2,8 +2,18 @@ import ipywidgets as ipw
 import pytest
 from ipyautoui.custom.iterable import ItemBox, ItemControl
 
-from stellarphot.settings import Camera, Observatory, PassbandMap, SavedSettings
-from stellarphot.settings.custom_widgets import ChooseOrMakeNew, Confirm
+from stellarphot.settings import (
+    Camera,
+    Observatory,
+    PassbandMap,
+    SavedSettings,
+    ui_generator,
+)
+from stellarphot.settings.custom_widgets import (
+    ChooseOrMakeNew,
+    Confirm,
+    SettingWithTitle,
+)
 from stellarphot.settings.tests.test_models import (
     DEFAULT_OBSERVATORY_SETTINGS,
     DEFAULT_PASSBAND_MAP,
@@ -790,3 +800,65 @@ class TestConfirm:
         assert confirm.layout.display == "none"
         if other_widget:
             assert other_widget.layout.display != "none"
+
+
+class TestSettingWithTitle:
+    def test_title_format(self):
+        # Check that the title is formatted with the requested heading
+        # level and that there is, initially, no decoration.
+        # Make a Camera with default testing settings
+        camera = ui_generator(Camera)
+
+        # Choosing something other than the default
+        header_level = 5
+
+        # Make a SettingWithTitle with the camera
+        plain_title = "I am a camera"
+        camera_title = SettingWithTitle(plain_title, camera, header_level=header_level)
+
+        assert (
+            camera_title.title.value
+            == f"<h{header_level}>{plain_title}</h{header_level}>"
+        )
+
+    def test_title_decoration(self):
+        # Check that the title has the correct decoration given the
+        # state of the settings widget.
+
+        # Make a camera ui
+        camera = ui_generator(Camera)
+
+        # Make a SettingWithTitle with the camera
+        plain_title = "I am a camera"
+        camera_title = SettingWithTitle(plain_title, camera)
+
+        # At the moment the title should not be decorated
+        assert camera_title.SETTING_IS_SAVED not in camera_title.title.value
+        assert camera_title.SETTING_NOT_SAVED not in camera_title.title.value
+
+        # There should also be no unsaved changes at the moment
+        assert not camera.savebuttonbar.unsaved_changes
+
+        # Manually set unsaved_changes then call the change handler, which should
+        # add an indication that there are unsaved changes.
+        camera.savebuttonbar.unsaved_changes = True
+        camera_title.decorate_title()
+        assert camera_title.SETTING_NOT_SAVED in camera_title.title.value
+
+        # Go back to unsaved_changes being False
+        camera.savebuttonbar.unsaved_changes = False
+
+        # Manually call the change handler, which should add an indication that
+        # saves have been done.
+        camera_title.decorate_title()
+        assert camera_title.SETTING_IS_SAVED in camera_title.title.value
+
+        # Now change the camera value, which should trigger the change handler
+        camera.value = TEST_CAMERA_VALUES
+
+        assert camera_title.SETTING_NOT_SAVED in camera_title.title.value
+
+        # Finally, click the save button and the title should be decorated with
+        # the saved indication.
+        camera.savebuttonbar.bn_save.click()
+        assert camera_title.SETTING_IS_SAVED in camera_title.title.value