diff --git a/docs/development/browser.md b/docs/development/browser.md index e764ab7b..0bf2e4d1 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -54,10 +54,10 @@ drive, and `get_all_reconstruction_files` reads the scan. |---|---|---| | Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it | | Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree | -| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation groups and names it by its generators; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | +| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation configuration headings and names it by its generators, so the rows leading to it spell its display name; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | | Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration | | Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first | -| Containers | `tree/containers.py` | `find_or_create_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each node type is looked up among the siblings of its own kind, so a folder and an audio sharing a name stay two rows | +| Containers | `tree/containers.py` | `find_or_create_group`, `find_or_create_config_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each heading is looked up among the siblings of its own kind and class, so a folder and an audio sharing a name stay two rows | The policy the two branches share: a configuration directory sitting at the top level of the reconstructions directory is the one lifted under groups and transposed into the sample view. A @@ -78,11 +78,15 @@ the branch which follows the disk. parsed `ConfigDirectoryFields`. It subclasses `FileSystemNode` so every reader of a path keeps working, and the fields travel with the row, which is what lets a label, a tooltip and a font state the configuration from the node already in hand. - -`create_directory_node` chooses between the last two from the fields the scan read. Which row carries -the configuration follows the branch: in the configuration branch it is the directory that names it, -and in the sample branch it is the variant leaf, since there the configuration is what distinguishes -one row from the next. +* `ConfigGroupNode` — a heading gathering the configurations that share a stretch of their display + name: the rates they run at, the spectrum they were built from. It keeps `NodeType.GROUP`, so it + folds, prunes, sorts and behaves as any heading does, and it names the rows whose labels are + configuration text rather than words, which is what the configuration font reads. + +`create_directory_node` chooses between `FileSystemNode` and `ConfigNode` from the fields the scan +read. Which row carries the configuration follows the branch: in the configuration branch it is the +directory that names it, and in the sample branch it is the variant leaf, since there the +configuration is what distinguishes one row from the next. ## The shaping rules diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 198ab3bc..b5b2a61c 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -11,6 +11,7 @@ * In-project sample selection in Reconstruction view * Playing a fragment by clicking on a waveform * Note pitch shown as a transpose offset rather than a note name +* Application installation progress bar ### Tracker @@ -26,6 +27,7 @@ * In-application guide/tutorial * Language selector +* NSF export ### Technical @@ -41,3 +43,4 @@ * No refreshing after library generation * Misaligned dialog boxes sizes at initialization +* Audible noise instructions when matching near-silent samples for FFT γ0 diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py index c98c3265..a214fe22 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py @@ -3,7 +3,7 @@ disambiguate_generator_siblings, ) from sampletones_application.logic.reconstruction.browser.tree.containers import ( - find_or_create_group, + find_or_create_config_group, ) from sampletones_core.configs.display import ( format_frequencies, @@ -20,9 +20,10 @@ def organize_top_level_config_directories(branch: TreeNode) -> None: """Groups top-level config directories under frequencies/transformation nodes, leaving other folders flat. - A config directory moves under ``frequencies`` ▶ ``transformation`` artificial group nodes and is - renamed to its generator abbreviation, while any other top-level folder keeps the existing - flat friendly naming for the config directories nested inside it. + A config directory moves under ``frequencies`` ▶ ``transformation`` configuration headings and is + renamed to its generator abbreviation, so the three rows leading to it spell its display name. + Any other top-level folder keeps the flat friendly naming for the config directories nested + inside it. """ for child in list(branch.children): match child: @@ -39,11 +40,11 @@ def _attach_config_directory_under_groups( branch: TreeNode, ) -> None: fields = directory_node.config - frequencies_node = find_or_create_group( + frequencies_node = find_or_create_config_group( format_frequencies(fields.sr, fields.nf), parent=branch, ) - transformation_node = find_or_create_group( + transformation_node = find_or_create_config_group( format_transformation(fields.sm, fields.tg), parent=frequencies_node, ) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py index 18687ede..ff58894a 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py @@ -1,6 +1,6 @@ -from typing import Final, FrozenSet +from typing import Final, FrozenSet, Type, TypeVar -from sampletones_core.structures.tree import NodeType, TreeNode +from sampletones_core.structures.tree import ConfigGroupNode, NodeType, TreeNode ARTIFICIAL_CONTAINERS: Final[FrozenSet[NodeType]] = frozenset( { @@ -9,6 +9,8 @@ } ) +NodeT = TypeVar("NodeT", bound=TreeNode) + def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: """Answers the group of this name under ``parent``, adding one where the parent holds none. @@ -16,7 +18,27 @@ def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: A group stands for something the disk states rather than holds — a frequency pair, a spectrum method, a source folder — so a builder meeting that name again extends the group it already made. """ - return _find_or_create(name, node_type=NodeType.GROUP, parent=parent) + return _find_or_create( + name, + node_class=TreeNode, + node_type=NodeType.GROUP, + parent=parent, + ) + + +def find_or_create_config_group(name: str, *, parent: TreeNode) -> ConfigGroupNode: + """Answers the configuration heading of this name under ``parent``, adding one where it holds none. + + A configuration heading names a stretch of a configuration's display name, so it is looked up + among the configuration headings its parent already holds and states that much of the + configuration to every reader of the row. + """ + return _find_or_create( + name, + node_class=ConfigGroupNode, + node_type=NodeType.GROUP, + parent=parent, + ) def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode: @@ -26,17 +48,23 @@ def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode: node type of its own, so a folder and an audio of the same name stay two rows: each is looked up among the siblings of its own kind. """ - return _find_or_create(name, node_type=NodeType.SAMPLE, parent=parent) + return _find_or_create( + name, + node_class=TreeNode, + node_type=NodeType.SAMPLE, + parent=parent, + ) def _find_or_create( name: str, *, + node_class: Type[NodeT], node_type: NodeType, parent: TreeNode, -) -> TreeNode: +) -> NodeT: for child in parent.children: - if isinstance(child, TreeNode) and child.node_type == node_type and child.name == name: + if isinstance(child, node_class) and child.node_type == node_type and child.name == name: return child - return TreeNode(name, node_type=node_type, parent=parent) + return node_class(name, node_type=node_type, parent=parent) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index f63f5db6..6a3c48f0 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -92,6 +92,7 @@ from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( + ConfigGroupNode, ConfigNode, FileSystemNode, LibraryNode, @@ -750,13 +751,16 @@ def _node_header_color(self, node: TreeNode) -> BaseColor: return self._colors.node def _resolve_node_name_font(self, node: TreeNode) -> Font: - """Select the label font for a node: monospace for config-bearing nodes where the panel opts in. - - A config-bearing node carries the machine-generated fields a reconstruction or library - directory encodes, so a panel that sets ``_MONOSPACE_CONFIG_NODES`` renders those names in - the fixed-width font for legibility. Every other node keeps the panel's ``_NAME_FONT``. + """Select the label font for a node: monospace for the rows stating a configuration. + + A row states a configuration when its label is the machine-generated text a configuration + carries: the fields a reconstruction directory encodes, the stretch of them a heading gathers + several directories under, the name a library goes by. A panel that sets + ``_MONOSPACE_CONFIG_NODES`` renders every one of those rows in the fixed-width font, so a + column of them reads field under field and a heading reads as the row below it does. Every + other row keeps the panel's ``_NAME_FONT``. """ - if self._MONOSPACE_CONFIG_NODES and self._node_detail_items(node): + if self._MONOSPACE_CONFIG_NODES and isinstance(node, (ConfigNode, ConfigGroupNode, LibraryNode)): return self._CONFIG_FONT return self._NAME_FONT diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py index 94a9bdcf..44321012 100644 --- a/src/sampletones_core/structures/tree/__init__.py +++ b/src/sampletones_core/structures/tree/__init__.py @@ -1,6 +1,6 @@ from .arguments import Arguments from .factory import create_directory_node -from .node import ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode +from .node import ConfigGroupNode, ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode from .traversal import TreeTraversal, traverse from .tree import Tree from .type import NodeType @@ -8,6 +8,7 @@ __all__ = [ "Arguments", + "ConfigGroupNode", "ConfigNode", "FileSystemNode", "GeneratorNode", diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index 9f6b34c5..6176c4d2 100644 --- a/src/sampletones_core/structures/tree/node.py +++ b/src/sampletones_core/structures/tree/node.py @@ -81,6 +81,19 @@ def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode: ) +class ConfigGroupNode(TreeNode): + """A heading gathering the configurations that share a stretch of their display name. + + The browser lifts a configuration directory under the rates it runs at and the spectrum it was + built from, naming each heading with that stretch of the configuration's own display name. + Carrying the heading as a class of its own lets a reader of configuration text — a label, a + tooltip, a font — reach it the way it reaches the configuration row below. + """ + + def copy(self, parent: Optional[TreeNode] = None) -> ConfigGroupNode: + return ConfigGroupNode(self.name, node_type=self.node_type, parent=parent) + + class LibraryNode(TreeNode): def __init__( self, diff --git a/tests/suite/browser.py b/tests/suite/browser.py index d861995d..45eff6f2 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -1,8 +1,8 @@ from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from textwrap import dedent -from typing import Dict, Final, List, Mapping, Optional, Sequence, Set, Tuple +from typing import AbstractSet, Dict, Final, Iterable, List, Mapping, Optional, Sequence, Set, Tuple from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.ui.elements.tree.colors import TreeColors @@ -32,10 +32,13 @@ ARCHIVE: Final[str] = "archive" STRAY: Final[str] = "stray" +BY_CONFIGURATION: Final[str] = "By configuration" +BY_SAMPLE: Final[str] = "By sample" + BROWSER_TEXTS: Final[Mapping[str, str]] = { "global.browser.label.root": "Root", - "global.browser.label.by_configuration": "By configuration", - "global.browser.label.by_sample": "By sample", + "global.browser.label.by_configuration": BY_CONFIGURATION, + "global.browser.label.by_sample": BY_SAMPLE, } TREE_COLORS: Final[TreeColors] = TreeColors( @@ -104,6 +107,119 @@ def as_view(text: str) -> str: - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) +STARRED_CONFIGURATION: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT + > takes + - alt + - beat + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + +@dataclass(frozen=True) +class _Row: + """One row of a rendered view read apart: how deep it sits, the state it stands in, its text.""" + + depth: int + marker: str + text: str + + @property + def label(self) -> str: + return self.text.removesuffix(HIDDEN_MARKER) + + def opened(self) -> "_Row": + return replace(self, marker=OPEN_MARKER) if self.marker == CLOSED_MARKER else self + + +def with_rows_open(rendered: str, *labels: str) -> str: + """The view these rows read as once the rows under these labels stand open. + + A view differing from another by which rows are unfolded is written as the view it varies and the + labels of the rows standing open in it, so the rows themselves are stated the once. + """ + rows = _read_view(rendered) + wanted = frozenset(labels) + _assert_rows_named(rows, wanted) + return _write_view(row.opened() if row.label in wanted else row for row in rows) + + +def with_branch_open(rendered: str, *labels: str) -> str: + """The view these rows read as once the branches under these labels stand open throughout. + + A branch the browser opened all the way down stands open at every depth, which one label states + for the row carrying it and every row nested under it. + """ + rows = _read_view(rendered) + wanted = frozenset(labels) + _assert_rows_named(rows, wanted) + opened = _rows_of_branches(rows, wanted) + return _write_view(row.opened() if index in opened else row for index, row in enumerate(rows)) + + +def without_branch(rendered: str, label: str) -> str: + """The view left once the row under this label and the rows nested under it are gone. + + A model dropping a folder drops the rows below it as well, so the view of what remains is written + as the whole view apart from that branch. + """ + rows = _read_view(rendered) + wanted = frozenset({label}) + _assert_rows_named(rows, wanted) + dropped = _rows_of_branches(rows, wanted) + return _write_view(row for index, row in enumerate(rows) if index not in dropped) + + +def _read_view(rendered: str) -> List[_Row]: + return [_read_row(line) for line in rendered.splitlines()] + + +def _read_row(line: str) -> _Row: + marker, _, text = line.lstrip().partition(" ") + return _Row( + depth=(len(line) - len(line.lstrip())) // len(INDENT), + marker=marker, + text=text, + ) + + +def _write_view(rows: Iterable[_Row]) -> str: + return "\n".join(f"{INDENT * row.depth}{row.marker} {row.text}" for row in rows) + + +def _rows_of_branches(rows: Sequence[_Row], labels: AbstractSet[str]) -> Set[int]: + """Every row the named branches reach: each row carrying a label, and the rows nested under it.""" + reached: Set[int] = set() + for index, row in enumerate(rows): + if row.label in labels: + reached.add(index) + reached.update(_rows_nested_under(rows, index)) + + return reached + + +def _rows_nested_under(rows: Sequence[_Row], index: int) -> Set[int]: + nested: Set[int] = set() + for nested_index in range(index + 1, len(rows)): + if rows[nested_index].depth <= rows[index].depth: + break + + nested.add(nested_index) + + return nested + + +def _assert_rows_named(rows: Sequence[_Row], labels: AbstractSet[str]) -> None: + """Holds a derived view to the rows the view it varies reads, so a label naming none is stated.""" + missing = labels - {row.label for row in rows} + assert not missing, f"the view holds no row labelled {sorted(missing)}" + def config_fields( *, @@ -396,6 +512,11 @@ def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str: return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER +def paths_of(corpus: BrowserCorpus, *keys: str) -> Set[Path]: + """The paths a test stars, named as the corpus lays them out.""" + return {corpus.paths[key] for key in keys} + + def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]: """Every row standing for one path, which is what a favorite reaches across the two views.""" path = corpus.paths[key] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py index c8a5bdea..0da43ad9 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py @@ -14,6 +14,7 @@ from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( + ConfigGroupNode, ConfigNode, FileSystemNode, NodeType, @@ -73,6 +74,23 @@ def test_config_directory_groups_by_frequencies_then_transformation(self) -> Non assert set(directory_children(transformations[transformation_name(fields)])) == {fields.gn} + def test_both_headings_state_the_configuration_they_gather(self) -> None: + """The headings name a stretch of the display name, so they read as configuration text.""" + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + frequencies_node = group_children(branch)[frequencies_name(fields)] + transformation_node = group_children(frequencies_node)[transformation_name(fields)] + + assert isinstance(frequencies_node, ConfigGroupNode) + assert isinstance(transformation_node, ConfigGroupNode) + + def test_the_branch_root_is_a_heading_of_its_own(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + assert not isinstance(branch, ConfigGroupNode) + def test_config_directory_keeps_its_reconstructions(self) -> None: fields = config_fields() entry = config_entry(fields, "song") diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py index 57eeece4..363af4a1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py @@ -9,7 +9,7 @@ ) from sampletones_core.configs.display import disambiguated_display_name from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode +from sampletones_core.structures.tree import ConfigGroupNode, ConfigNode, NodeType, TreeNode from .conftest import ( BRANCH_NAME, @@ -101,6 +101,19 @@ def test_audio_is_a_sample_and_the_folder_above_it_is_a_group(self) -> None: assert folder_node.node_type == NodeType.GROUP assert sample_children(folder_node)["cw_amen02_165"].node_type == NodeType.SAMPLE + def test_the_folder_a_source_came_from_states_no_configuration(self) -> None: + """A source folder is named by the disk, so it reads as a folder rather than as a heading.""" + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=(reconstruction_entry(directory, "Amen Breaks", "cw_amen02_165"),), + ) + branch = build_branch(scan_of(entry)) + + assert not isinstance(group_children(branch)["Amen Breaks"], ConfigGroupNode) + def test_a_folder_and_the_audio_beside_it_stay_two_rows(self) -> None: """A configuration directory holding ``song.stn`` beside ``song/inner.stn`` lists both. diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py index 1c3c57ff..3cae480a 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -6,9 +6,14 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.configs import Config -from sampletones_core.configs.display import format_sample_rate, short_hash +from sampletones_core.configs.display import format_frequencies, format_sample_rate, short_hash from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode +from sampletones_core.structures.tree.node import ( + ConfigGroupNode, + ConfigNode, + FileSystemNode, + TreeNode, +) from sampletones_core.structures.tree.type import NodeType from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager @@ -53,6 +58,21 @@ def config_directory_node() -> ConfigNode: ) +def config_group_node() -> ConfigGroupNode: + return ConfigGroupNode( + format_frequencies(CONFIG_FIELDS.sr, CONFIG_FIELDS.nf), + node_type=NodeType.GROUP, + ) + + +def plain_directory_node() -> FileSystemNode: + return FileSystemNode( + "my_songs", + node_type=NodeType.DIRECTORY, + filepath=Path("/reconstructions/my_songs"), + ) + + def config_variant_node() -> ConfigNode: return ConfigNode( CONFIG_FIELDS.display_name, @@ -82,26 +102,49 @@ def test_config_variant_leaf_states_the_same_configuration( """ assert panel._node_detail_items(config_variant_node()) == panel._node_detail_items(config_directory_node()) + def test_plain_directory_states_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._node_detail_items(plain_directory_node()) == [] + + def test_group_states_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == [] + + +class TestConfigurationFont: + """Every row whose label is configuration text reads in one font, whatever kind of row it is.""" + + def test_config_directory_reads_in_the_configuration_font( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._resolve_node_name_font(config_directory_node()) == Font.MONO_SMALL + def test_config_variant_leaf_reads_in_the_configuration_font( self, panel: GUISequencerBrowserPanel, ) -> None: assert panel._resolve_node_name_font(config_variant_node()) == Font.MONO_SMALL - def test_plain_directory_states_nothing( + def test_configuration_heading_reads_in_the_configuration_font( self, panel: GUISequencerBrowserPanel, ) -> None: - node = FileSystemNode( - "my_songs", - node_type=NodeType.DIRECTORY, - filepath=Path("/reconstructions/my_songs"), - ) - assert panel._node_detail_items(node) == [] - assert panel._resolve_node_name_font(node) == Font.REGULAR_SMALL + """A heading gathers a stretch of the configuration, so it reads as the rows below it do.""" + assert panel._resolve_node_name_font(config_group_node()) == Font.MONO_SMALL - def test_group_states_nothing( + def test_plain_directory_reads_in_the_name_font( self, panel: GUISequencerBrowserPanel, ) -> None: - assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == [] + assert panel._resolve_node_name_font(plain_directory_node()) == Font.REGULAR_SMALL + + def test_heading_the_disk_names_reads_in_the_name_font( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._resolve_node_name_font(TreeNode("Amen Breaks", NodeType.GROUP)) == Font.REGULAR_SMALL diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 5b133df6..8dcfa73c 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -5,155 +5,59 @@ from sampletones_application.ui.elements.tree import tree as tree_module from tests.suite.browser import ( + ARCHIVE, + BY_CONFIGURATION, + STARRED_CONFIGURATION, WHOLE_TREE, BrowserCorpus, - as_view, build_browser_panel, build_corpus, click_favorites, deselect_favorites, nodes_at, + paths_of, render_view, resolve_pass, row_named, select_favorites, set_row_expanded, + with_rows_open, + without_branch, ) -STARRED_CONFIGURATION: Final[str] = as_view(""" - > By configuration - > 44.1 kHz·30 Hz - > FFT·γ0 - > PT - > takes - - alt - - beat - > By sample - > beat - - 44.1 kHz·30 Hz·FFT·γ0·PT - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) -SUBFOLDER_THE_READER_OPENED: Final[str] = as_view(""" - > By configuration - > 44.1 kHz·30 Hz - > FFT·γ0 - > PT - v takes - - alt - - beat - > By sample - > beat - - 44.1 kHz·30 Hz·FFT·γ0·PT - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) -THE_WAY_DOWN_TO_THE_READERS_ROW: Final[str] = as_view(""" - v By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - v 44.1 kHz·30 Hz - > CQT·γ0·PTN - - beat - - solo - v FFT·γ0 - > PT - > takes - - alt - - beat - > PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - v PTN·#bbbbbbb - > drums - - kick - - beat - - melody - > archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - - stray - > By sample - > beat - - 44.1 kHz·30 Hz·CQT·γ0·PTN - - 44.1 kHz·30 Hz·FFT·γ0·PT - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - - sweep·8 kHz·60 Hz·CQT·γ2·P - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) -WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" - > By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - > 44.1 kHz·30 Hz - > CQT·γ0·PTN - - beat - - solo - > FFT·γ0 - > PT - > takes - - alt - - beat - > PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - - stray - > By sample - > beat - - 44.1 kHz·30 Hz·CQT·γ0·PTN - - 44.1 kHz·30 Hz·FFT·γ0·PT - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - - sweep·8 kHz·60 Hz·CQT·γ2·P - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) +FREQUENCIES: Final[str] = "44.1 kHz·30 Hz" +TRANSFORMATION: Final[str] = "FFT·γ0" +CONFIGURATION_B_ROW: Final[str] = "PTN·#bbbbbbb" +SUBFOLDER: Final[str] = "takes" + +SUBFOLDER_THE_READER_OPENED: Final[str] = with_rows_open(STARRED_CONFIGURATION, SUBFOLDER) +THE_WAY_DOWN_TO_THE_READERS_ROW: Final[str] = with_rows_open( + WHOLE_TREE, + BY_CONFIGURATION, + FREQUENCIES, + TRANSFORMATION, + CONFIGURATION_B_ROW, +) +WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = without_branch(WHOLE_TREE, ARCHIVE) class TestTheReadersShape: """A row stands where the reader left it, and a later pass brings it back that way.""" def test_a_row_the_reader_opened_is_drawn_open(self, corpus: BrowserCorpus) -> None: - panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True) + panel = build_browser_panel(corpus, paths_of(corpus, "C"), favorites_only=True) assert render_view(panel) == STARRED_CONFIGURATION - set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + set_row_expanded(panel, row_named(corpus, SUBFOLDER), expanded=True) assert render_view(panel) == SUBFOLDER_THE_READER_OPENED def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> None: - panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True) - set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + panel = build_browser_panel(corpus, paths_of(corpus, "C"), favorites_only=True) + set_row_expanded(panel, row_named(corpus, SUBFOLDER), expanded=True) render_view(panel) - set_row_expanded(panel, row_named(corpus, "takes"), expanded=False) + set_row_expanded(panel, row_named(corpus, SUBFOLDER), expanded=False) assert render_view(panel) == STARRED_CONFIGURATION @@ -161,7 +65,7 @@ def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: Brows """What the browser unfolded to show a favorite is the mode's, so the shape is left untouched.""" panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) @@ -183,7 +87,7 @@ def test_the_pass_that_follows_the_click_is_what_hands_the_modes_rows_back( """ panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) @@ -209,14 +113,14 @@ def test_the_way_down_to_a_row_the_reader_opened_stands_once_the_mode_goes_off( """ panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) - for label in ("By configuration", "44.1 kHz·30 Hz", "FFT·γ0", "PTN·#bbbbbbb"): + for label in (BY_CONFIGURATION, FREQUENCIES, TRANSFORMATION, CONFIGURATION_B_ROW): set_row_expanded(panel, row_named(corpus, label), expanded=True) - set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False) + set_row_expanded(panel, row_named(corpus, TRANSFORMATION), expanded=False) select_favorites(panel) render_view(panel) deselect_favorites(panel) @@ -230,12 +134,12 @@ def test_the_way_down_the_mode_hands_over_is_written_down_with_the_readers_rows( """A row the reader's own came to stand on is theirs from then on, so a session brings it back.""" panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) - heading = row_named(corpus, "FFT·γ0") - set_row_expanded(panel, row_named(corpus, "PTN·#bbbbbbb"), expanded=True) + heading = row_named(corpus, TRANSFORMATION) + set_row_expanded(panel, row_named(corpus, CONFIGURATION_B_ROW), expanded=True) select_favorites(panel) render_view(panel) @@ -247,14 +151,14 @@ def test_a_row_the_reader_folds_while_the_mode_is_on_stays_folded(self, corpus: """A row is the reader's to fold whichever hand opened it, so the mode lets go of its claim.""" panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) select_favorites(panel) render_view(panel) - set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False) + set_row_expanded(panel, row_named(corpus, TRANSFORMATION), expanded=False) resolve_pass(panel) assert "> FFT·γ0" in render_view(panel) @@ -262,7 +166,7 @@ def test_a_row_the_reader_folds_while_the_mode_is_on_stays_folded(self, corpus: def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) @@ -272,8 +176,8 @@ def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus assert panel.expanded_rows == set() def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserCorpus) -> None: - panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) - set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + panel = build_browser_panel(corpus, paths_of(corpus, "A/beat"), favorites_only=False) + set_row_expanded(panel, row_named(corpus, ARCHIVE), expanded=True) render_view(panel) select_favorites(panel) @@ -289,7 +193,7 @@ def test_a_refresh_brings_the_rows_back_standing_as_they_were( ) -> None: """A rebuilt model states the same rows, and a row is remembered by the ancestry it reads.""" panel = build_browser_panel(corpus, set(), favorites_only=False) - set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + set_row_expanded(panel, row_named(corpus, ARCHIVE), expanded=True) render_view(panel) panel.tree = build_corpus(tmp_path).tree @@ -301,7 +205,7 @@ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( corpus: BrowserCorpus, ) -> None: panel = build_browser_panel(corpus, set(), favorites_only=False) - archive = row_named(corpus, "archive") + archive = row_named(corpus, ARCHIVE) set_row_expanded(panel, archive, expanded=True) render_view(panel) @@ -313,7 +217,7 @@ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( def test_a_browser_opens_with_the_rows_a_session_left_it(self, corpus: BrowserCorpus) -> None: """The shape outlives the run it was made in, so a browser is handed it as it is built.""" panel = build_browser_panel(corpus, set(), favorites_only=False) - archive_tag = panel._generate_node_tag(row_named(corpus, "archive")) + archive_tag = panel._generate_node_tag(row_named(corpus, ARCHIVE)) opened = build_browser_panel( corpus, @@ -329,8 +233,8 @@ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( corpus: BrowserCorpus, ) -> None: """The model states which rows exist whatever the mode narrows to, so a lost row is dropped.""" - panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) - archive = row_named(corpus, "archive") + panel = build_browser_panel(corpus, paths_of(corpus, "A/beat"), favorites_only=False) + archive = row_named(corpus, ARCHIVE) set_row_expanded(panel, archive, expanded=True) render_view(panel) @@ -342,18 +246,18 @@ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( def test_the_shape_a_save_writes_is_the_rows_standing_open(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel(corpus, set(), favorites_only=False) - archive_tag = panel._generate_node_tag(row_named(corpus, "archive")) - set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + archive_tag = panel._generate_node_tag(row_named(corpus, ARCHIVE)) + set_row_expanded(panel, row_named(corpus, ARCHIVE), expanded=True) assert panel.expanded_rows == {archive_tag} def test_the_shape_a_save_reads_is_taken_apart_from_the_browser(self, corpus: BrowserCorpus) -> None: """The browser keeps writing its own memory, so what a save carries is a reading of it.""" panel = build_browser_panel(corpus, set(), favorites_only=False) - set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + set_row_expanded(panel, row_named(corpus, ARCHIVE), expanded=True) written = panel.expanded_rows - set_row_expanded(panel, row_named(corpus, "archive"), expanded=False) + set_row_expanded(panel, row_named(corpus, ARCHIVE), expanded=False) assert written != panel.expanded_rows @@ -362,7 +266,7 @@ def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: Brows sequencer = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="sequencer.browser") reconstruction = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="reconstruction.browser") - set_row_expanded(sequencer, row_named(corpus, "archive"), expanded=True) + set_row_expanded(sequencer, row_named(corpus, ARCHIVE), expanded=True) assert "v archive" in render_view(sequencer) assert render_view(reconstruction) == WHOLE_TREE @@ -384,7 +288,7 @@ def test_a_click_reads_the_row_the_frame_after_it_landed( lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)), ) - panel._remember_clicked_row((row_named(corpus, "archive"), "row.tag")) + panel._remember_clicked_row((row_named(corpus, ARCHIVE), "row.tag")) assert scheduled == [(panel._read_row_expansion, ("row.tag",), {"delay": 1})] diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index c977916c..2e34b420 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Any, Final, List, Tuple import pytest @@ -5,10 +6,15 @@ from sampletones_application.ui.elements.tree import tree as tree_module from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.structures.tree import TreeNode +from tests.suite.base import BaseTestSuite from tests.suite.browser import ( + ARCHIVE, + BY_CONFIGURATION, + BY_SAMPLE, CLOSED_MARKER, OPEN_MARKER, PANEL_TAG, + STARRED_CONFIGURATION, TREE_COLORS, WHOLE_TREE, BrowserCorpus, @@ -16,16 +22,24 @@ as_view, build_browser_panel, nodes_at, + paths_of, render_view, resolve_pass, select_favorites, view, view_on_selecting_favorites, + with_branch_open, + with_rows_open, ) +from tests.suite.case import BaseRegularTestCase CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" +FREQUENCIES: Final[str] = "44.1 kHz·30 Hz" +TRANSFORMATION: Final[str] = "FFT·γ0" +CONFIGURATION_B_ROW: Final[str] = "PTN·#bbbbbbb" + def rows_of(rendered: str) -> List[str]: """The rows a view holds, read apart from the state each of them stands in.""" @@ -42,16 +56,6 @@ def rows_of(rendered: str) -> List[str]: > beat - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) -STARRED_RECONSTRUCTION_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PTN·#aaaaaaa - - beat - v By sample - v beat - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - """) STARRED_LONE_AUDIO: Final[str] = as_view(""" > By configuration > 44.1 kHz·30 Hz @@ -60,14 +64,6 @@ def rows_of(rendered: str) -> List[str]: > By sample - solo·44.1 kHz·30 Hz·CQT·γ0·PTN """) -STARRED_LONE_AUDIO_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v CQT·γ0·PTN - - solo - v By sample - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - """) STARRED_IN_SUBFOLDER: Final[str] = as_view(""" > By configuration > 44.1 kHz·30 Hz @@ -80,70 +76,16 @@ def rows_of(rendered: str) -> List[str]: > kick - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) -STARRED_IN_SUBFOLDER_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PTN·#aaaaaaa - v drums - - kick - v By sample - v drums - v kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - """) -STARRED_CONFIGURATION: Final[str] = as_view(""" - > By configuration - > 44.1 kHz·30 Hz - > FFT·γ0 - > PT - > takes - - alt - - beat - > By sample - > beat - - 44.1 kHz·30 Hz·FFT·γ0·PT - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) -STARRED_CONFIGURATION_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - > PT - > takes - - alt - - beat - v By sample - v beat - - 44.1 kHz·30 Hz·FFT·γ0·PT - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) STARRED_PLAIN_FOLDER: Final[str] = as_view(""" > By configuration > archive > 48 kHz·50 Hz·LogFFT·γ1·TN - song """) -STARRED_PLAIN_FOLDER_OPENED: Final[str] = as_view(""" - v By configuration - > archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - """) -STARRED_FOLDER_IN_STARRED_FOLDER_OPENED: Final[str] = as_view(""" - v By configuration - v archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - """) STARRED_STRAY: Final[str] = as_view(""" > By configuration - stray """) -STARRED_STRAY_OPENED: Final[str] = as_view(""" - v By configuration - - stray - """) STARRED_OF_TWO_ALIKE: Final[str] = as_view(""" > By configuration > 44.1 kHz·30 Hz @@ -164,26 +106,6 @@ def rows_of(rendered: str) -> List[str]: > melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) -STARRED_OF_TWO_ALIKE_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - > PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - v By sample - v beat - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - v drums - v kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - v melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - """) STARRED_FOLDED_CONFIGURATION: Final[str] = as_view(""" > By configuration > 8 kHz·60 Hz·CQT·γ2·P @@ -191,13 +113,6 @@ def rows_of(rendered: str) -> List[str]: > By sample - sweep·8 kHz·60 Hz·CQT·γ2·P """) -STARRED_FOLDED_CONFIGURATION_OPENED: Final[str] = as_view(""" - v By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - v By sample - - sweep·8 kHz·60 Hz·CQT·γ2·P - """) STARRED_CONFIGURATION_B: Final[str] = as_view(""" > By configuration > 44.1 kHz·30 Hz @@ -216,60 +131,70 @@ def rows_of(rendered: str) -> List[str]: > melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb """) -STARRED_CONFIGURATION_B_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - v By sample - v beat - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - v drums - v kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - v melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - """) -STARRED_FOLDER_HOLDING_A_STAR_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PTN·#bbbbbbb - v drums - - kick - - beat - - melody - v By sample - > beat - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - v drums - v kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - """) -STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - v By sample - v beat - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - v melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - """) + +STARRED_RECONSTRUCTION_OPENED: Final[str] = with_branch_open(STARRED_RECONSTRUCTION, BY_CONFIGURATION, BY_SAMPLE) +STARRED_LONE_AUDIO_OPENED: Final[str] = with_branch_open(STARRED_LONE_AUDIO, BY_CONFIGURATION, BY_SAMPLE) +STARRED_IN_SUBFOLDER_OPENED: Final[str] = with_branch_open(STARRED_IN_SUBFOLDER, BY_CONFIGURATION, BY_SAMPLE) +STARRED_CONFIGURATION_OPENED: Final[str] = with_branch_open( + with_rows_open( + STARRED_CONFIGURATION, + BY_CONFIGURATION, + FREQUENCIES, + TRANSFORMATION, + ), + BY_SAMPLE, +) +STARRED_PLAIN_FOLDER_OPENED: Final[str] = with_rows_open(STARRED_PLAIN_FOLDER, BY_CONFIGURATION) +STARRED_FOLDER_IN_STARRED_FOLDER_OPENED: Final[str] = with_rows_open( + STARRED_PLAIN_FOLDER, + BY_CONFIGURATION, + ARCHIVE, +) +STARRED_STRAY_OPENED: Final[str] = with_rows_open(STARRED_STRAY, BY_CONFIGURATION) +STARRED_OF_TWO_ALIKE_OPENED: Final[str] = with_branch_open( + with_rows_open( + STARRED_OF_TWO_ALIKE, + BY_CONFIGURATION, + FREQUENCIES, + TRANSFORMATION, + ), + BY_SAMPLE, +) +STARRED_FOLDED_CONFIGURATION_OPENED: Final[str] = with_rows_open( + STARRED_FOLDED_CONFIGURATION, + BY_CONFIGURATION, + BY_SAMPLE, +) +STARRED_CONFIGURATION_B_OPENED: Final[str] = with_branch_open( + with_rows_open( + STARRED_CONFIGURATION_B, + BY_CONFIGURATION, + FREQUENCIES, + TRANSFORMATION, + ), + BY_SAMPLE, +) +STARRED_FOLDER_HOLDING_A_STAR_OPENED: Final[str] = with_branch_open( + with_rows_open( + STARRED_CONFIGURATION_B, + BY_CONFIGURATION, + FREQUENCIES, + TRANSFORMATION, + CONFIGURATION_B_ROW, + BY_SAMPLE, + ), + "drums", +) +STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER: Final[str] = with_rows_open( + STARRED_CONFIGURATION_B, + BY_CONFIGURATION, + FREQUENCIES, + TRANSFORMATION, + BY_SAMPLE, + "beat", + "melody", +) + QUERY_INSIDE_THE_MODE: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz @@ -346,49 +271,82 @@ def rows_of(rendered: str) -> List[str]: """) -class TestDrawnRows: +class TestDrawnRows(BaseTestSuite): """Which rows the mode draws: what the star reaches, and the rows leading down to it. What is drawn is the star's to state and nothing else, so every row stands folded here: the mode - is stated the way a session restores it, and a mode nobody asked for opens no row. + is stated the way a session restores it, and a mode nobody asked for opens no row. A star belongs + to a path, so a configuration reading like its sibling stays out while that sibling is drawn, and + the sample branch reads the top-level configurations, leaving a nested one to stand there alone. """ - def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION - - def test_a_starred_reconstruction_of_an_audio_one_configuration_holds(self, corpus: BrowserCorpus) -> None: - """A sample of a single variant folded into that variant, and the fold carries the star.""" - assert view(corpus, {corpus.paths["D/solo"]}, favorites_only=True) == STARRED_LONE_AUDIO - - def test_a_starred_reconstruction_in_a_mirrored_subfolder(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/drums/kick"]}, favorites_only=True) == STARRED_IN_SUBFOLDER - - def test_a_starred_configuration_directory_brings_what_it_holds(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION - - def test_a_starred_plain_folder_reaches_the_configuration_nested_in_it(self, corpus: BrowserCorpus) -> None: - """The sample branch reads the top-level configurations, so a nested one stands there alone.""" - assert view(corpus, {corpus.paths["archive"]}, favorites_only=True) == STARRED_PLAIN_FOLDER - - def test_a_starred_reconstruction_outside_every_configuration(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["stray"]}, favorites_only=True) == STARRED_STRAY - - def test_a_star_on_one_of_two_configurations_reading_alike(self, corpus: BrowserCorpus) -> None: - """The star belongs to a path, so the sibling marked with the other hash stays out.""" - assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE - - def test_a_starred_configuration_whose_chain_folded_into_one_row(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["E"]}, favorites_only=True) == STARRED_FOLDED_CONFIGURATION - - def test_nothing_starred_draws_no_row(self, corpus: BrowserCorpus) -> None: - assert view(corpus, set(), favorites_only=True) == "" + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + starred: Tuple[str, ...] + expected: str + + test_cases = ( + TestCase( + starred=("A/beat",), + expected=STARRED_RECONSTRUCTION, + label="a_starred_reconstruction_is_drawn_in_both_views", + ), + TestCase( + starred=("D/solo",), + expected=STARRED_LONE_AUDIO, + label="a_starred_reconstruction_of_an_audio_one_configuration_holds", + ), + TestCase( + starred=("A/drums/kick",), + expected=STARRED_IN_SUBFOLDER, + label="a_starred_reconstruction_in_a_mirrored_subfolder", + ), + TestCase( + starred=("C",), + expected=STARRED_CONFIGURATION, + label="a_starred_configuration_directory_brings_what_it_holds", + ), + TestCase( + starred=("archive",), + expected=STARRED_PLAIN_FOLDER, + label="a_starred_plain_folder_reaches_the_configuration_nested_in_it", + ), + TestCase( + starred=("stray",), + expected=STARRED_STRAY, + label="a_starred_reconstruction_outside_every_configuration", + ), + TestCase( + starred=("A",), + expected=STARRED_OF_TWO_ALIKE, + label="a_star_on_one_of_two_configurations_reading_alike", + ), + TestCase( + starred=("E",), + expected=STARRED_FOLDED_CONFIGURATION, + label="a_starred_configuration_whose_chain_folded_into_one_row", + ), + TestCase( + starred=(), + expected="", + label="nothing_starred_draws_no_row", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rows_a_star_draws( + self, + corpus: BrowserCorpus, + test_case: TestCase, + ) -> None: + assert view(corpus, paths_of(corpus, *test_case.starred), favorites_only=True) == test_case.expected def test_the_mode_off_draws_every_row(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=False) == WHOLE_TREE + assert view(corpus, paths_of(corpus, "A/beat"), favorites_only=False) == WHOLE_TREE def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: BrowserCorpus) -> None: """Opening the way down to a star is a separate answer, so it moves no row in or out.""" - favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + favorites = paths_of(corpus, "B", "B/drums/kick") assert rows_of( view_on_selecting_favorites( corpus, @@ -399,168 +357,123 @@ def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: ) == rows_of(view(corpus, favorites, favorites_only=True)) -class TestOpenRows: - """Which rows stand open: the way down to a star the reader asked the browser to follow.""" - - def test_the_preference_off_opens_nothing(self, corpus: BrowserCorpus) -> None: - assert view_on_selecting_favorites(corpus, {corpus.paths["A/beat"]}) == STARRED_RECONSTRUCTION - - def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["A/beat"]}, - auto_expand_reconstructions=True, - ) - == STARRED_RECONSTRUCTION_OPENED - ) - - def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( - self, - corpus: BrowserCorpus, - ) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["D/solo"]}, - auto_expand_reconstructions=True, - ) - == STARRED_LONE_AUDIO_OPENED - ) - - def test_the_subfolder_above_a_starred_reconstruction_opens(self, corpus: BrowserCorpus) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["A/drums/kick"]}, - auto_expand_reconstructions=True, - ) - == STARRED_IN_SUBFOLDER_OPENED - ) - - def test_the_branch_above_a_starred_reconstruction_outside_every_configuration_opens( - self, - corpus: BrowserCorpus, - ) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["stray"]}, - auto_expand_reconstructions=True, - ) - == STARRED_STRAY_OPENED - ) +class TestOpenRows(BaseTestSuite): + """Which rows stand open: the way down to a star the reader asked the browser to follow. - def test_a_starred_folder_is_left_folded_while_reconstructions_alone_are_followed( - self, - corpus: BrowserCorpus, - ) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["A"]}, - auto_expand_reconstructions=True, - ) - == STARRED_OF_TWO_ALIKE - ) - - def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_followed( - self, - corpus: BrowserCorpus, - ) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["A/beat"]}, - auto_expand_directories=True, - ) - == STARRED_RECONSTRUCTION - ) - - def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["C"]}, - auto_expand_directories=True, - ) - == STARRED_CONFIGURATION_OPENED - ) - - def test_the_rows_above_a_starred_plain_folder_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["archive"]}, - auto_expand_directories=True, - ) - == STARRED_PLAIN_FOLDER_OPENED - ) - - def test_a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it( - self, - corpus: BrowserCorpus, - ) -> None: - """The folder above stands on the way to the star below, which is what opens it.""" - favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} - assert ( - view_on_selecting_favorites( - corpus, - favorites, - auto_expand_directories=True, - ) - == STARRED_FOLDER_IN_STARRED_FOLDER_OPENED - ) - - def test_a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed( - self, - corpus: BrowserCorpus, - ) -> None: - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["E"]}, - auto_expand_directories=True, - ) - == STARRED_FOLDED_CONFIGURATION_OPENED - ) + A star is followed by the kind of thing it marks, so a reconstruction and a folder each answer to + their own preference: the way down to a starred folder opens while the folder itself stays folded, + a star inside a starred folder opens that folder once reconstructions are followed, and a folder + standing on the way to a star below it opens with the rest of that way. In the sample branch no + row stands for a folder, so a folder's star arrives at the variants it holds. + """ - def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + starred: Tuple[str, ...] + expected: str + auto_expand_reconstructions: bool = False + auto_expand_directories: bool = False + + test_cases = ( + TestCase( + starred=("A/beat",), + expected=STARRED_RECONSTRUCTION, + label="the_preference_off_opens_nothing", + ), + TestCase( + starred=("A/beat",), + auto_expand_reconstructions=True, + expected=STARRED_RECONSTRUCTION_OPENED, + label="the_rows_above_a_starred_reconstruction_open", + ), + TestCase( + starred=("D/solo",), + auto_expand_reconstructions=True, + expected=STARRED_LONE_AUDIO_OPENED, + label="the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens", + ), + TestCase( + starred=("A/drums/kick",), + auto_expand_reconstructions=True, + expected=STARRED_IN_SUBFOLDER_OPENED, + label="the_subfolder_above_a_starred_reconstruction_opens", + ), + TestCase( + starred=("stray",), + auto_expand_reconstructions=True, + expected=STARRED_STRAY_OPENED, + label="the_branch_above_a_starred_reconstruction_outside_every_configuration_opens", + ), + TestCase( + starred=("A",), + auto_expand_reconstructions=True, + expected=STARRED_OF_TWO_ALIKE, + label="a_starred_folder_is_left_folded_while_reconstructions_alone_are_followed", + ), + TestCase( + starred=("A/beat",), + auto_expand_directories=True, + expected=STARRED_RECONSTRUCTION, + label="a_starred_reconstruction_is_left_folded_while_directories_alone_are_followed", + ), + TestCase( + starred=("C",), + auto_expand_directories=True, + expected=STARRED_CONFIGURATION_OPENED, + label="the_rows_above_a_starred_configuration_open_and_it_stays_folded", + ), + TestCase( + starred=("archive",), + auto_expand_directories=True, + expected=STARRED_PLAIN_FOLDER_OPENED, + label="the_rows_above_a_starred_plain_folder_open_and_it_stays_folded", + ), + TestCase( + starred=("archive", "archive/F"), + auto_expand_directories=True, + expected=STARRED_FOLDER_IN_STARRED_FOLDER_OPENED, + label="a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it", + ), + TestCase( + starred=("E",), + auto_expand_directories=True, + expected=STARRED_FOLDED_CONFIGURATION_OPENED, + label="a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed", + ), + TestCase( + starred=("B",), + auto_expand_directories=True, + expected=STARRED_CONFIGURATION_B_OPENED, + label="the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds", + ), + TestCase( + starred=("B", "B/drums/kick"), + auto_expand_reconstructions=True, + expected=STARRED_FOLDER_HOLDING_A_STAR_OPENED, + label="a_star_inside_a_starred_folder_opens_that_folder", + ), + TestCase( + starred=("B", "B/drums/kick"), + auto_expand_directories=True, + expected=STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER, + label="a_star_inside_a_starred_folder_takes_its_own_preference", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rows_the_mode_opens( self, corpus: BrowserCorpus, + test_case: TestCase, ) -> None: - """No row stands for the folder there, so the variants are where the star arrives.""" - assert ( - view_on_selecting_favorites( - corpus, - {corpus.paths["B"]}, - auto_expand_directories=True, - ) - == STARRED_CONFIGURATION_B_OPENED - ) - - def test_a_star_inside_a_starred_folder_opens_that_folder(self, corpus: BrowserCorpus) -> None: - """A reconstruction answers by its own preference, so following those opens the folder above.""" - favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} - assert ( - view_on_selecting_favorites( - corpus, - favorites, - auto_expand_reconstructions=True, - ) - == STARRED_FOLDER_HOLDING_A_STAR_OPENED - ) - - def test_a_star_inside_a_starred_folder_takes_its_own_preference(self, corpus: BrowserCorpus) -> None: - """Following folders alone opens the way to the folder, leaving the star inside it folded away.""" - favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert ( view_on_selecting_favorites( corpus, - favorites, - auto_expand_directories=True, + paths_of(corpus, *test_case.starred), + auto_expand_reconstructions=test_case.auto_expand_reconstructions, + auto_expand_directories=test_case.auto_expand_directories, ) - == STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER + == test_case.expected ) def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> None: @@ -568,7 +481,7 @@ def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> assert ( view( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=True, auto_expand_reconstructions=True, ) @@ -579,7 +492,7 @@ def test_the_way_down_stands_open_for_as_long_as_the_mode_does(self, corpus: Bro """A refresh while the mode is on leaves the reader looking at the way down to their stars.""" panel = build_browser_panel( corpus, - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), favorites_only=False, auto_expand_reconstructions=True, ) @@ -600,7 +513,7 @@ def test_a_star_gained_while_the_mode_is_on_opens_no_way_of_its_own(self, corpus ) select_favorites(panel) panel._logic = FakeTreeLogic( # type: ignore[assignment] - {corpus.paths["A/beat"]}, + paths_of(corpus, "A/beat"), auto_expand_reconstructions=True, auto_expand_directories=False, ) diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py index 2f91cc30..15482d80 100644 --- a/tests/unit/sampletones_core/structures/tree/test_node.py +++ b/tests/unit/sampletones_core/structures/tree/test_node.py @@ -5,6 +5,7 @@ from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( + ConfigGroupNode, ConfigNode, FileSystemNode, GeneratorNode, @@ -101,6 +102,18 @@ def test_node_is_a_file_system_node(self) -> None: assert isinstance(node, FileSystemNode) +class TestConfigGroupNode: + def test_copy_preserves_name_and_type(self) -> None: + node = ConfigGroupNode("44.1 kHz·60 Hz", NodeType.GROUP) + copied = node.copy() + assert copied.name == "44.1 kHz·60 Hz" + assert copied.node_type == NodeType.GROUP + + def test_a_copy_is_a_configuration_heading_of_its_own(self) -> None: + node = ConfigGroupNode("FFT·γ0", NodeType.GROUP) + assert isinstance(node.copy(), ConfigGroupNode) + + class TestLibraryNode: def test_library_key_is_stored(self) -> None: node = LibraryNode("lib", library_key=LIBRARY_KEY)