diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6787bc557..ba613132b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,3 +90,16 @@ jobs: if: ${{ !cancelled() && steps.environment.outcome == 'success' }} shell: bash run: $PYTHON scripts/run_tests.py benchmarks + + - name: Run the screen scenarios (Linux) + id: screens + if: ${{ !cancelled() && steps.environment.outcome == 'success' && runner.os == 'Linux' }} + shell: bash + run: $PYTHON scripts/run_tests.py screens --screen-workers 2 + + - name: Keep what the failed screen scenarios left + if: ${{ failure() && steps.screens.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: screens-${{ matrix.os }}-py${{ matrix.python }} + path: build/screens/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3d32077d..c79d601d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -145,3 +145,13 @@ repos: stages: - pre-push pass_filenames: false + + - id: screens + name: screens + entry: make screens + language: system + types: + - python + stages: + - pre-push + pass_filenames: false diff --git a/Makefile b/Makefile index c856e87ad..58608083e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install system-deps build release run calibration tracker-playback clean pre-commit test test-docs benchmarks lint format +.PHONY: help setup install system-deps build release run calibration tracker-playback clean pre-commit test test-docs benchmarks screens lint format ifeq ($(OS),Windows_NT) PYTHON := python @@ -25,6 +25,7 @@ help: @echo $(Q) make test - Run the test suite with coverage$(Q) @echo $(Q) make test-docs - Run the doctests$(Q) @echo $(Q) make benchmarks - Run the measured-duration suite$(Q) + @echo $(Q) make screens - Run the screen scenarios: the application driven on a virtual display (needs Xvfb)$(Q) @echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q) @echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @@ -74,6 +75,9 @@ test-docs: benchmarks: $(PYTHON) scripts/run_tests.py benchmarks +screens: + $(PYTHON) scripts/run_tests.py screens + lint: $(PYTHON) scripts/lint.py $(ARGS) diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md new file mode 100644 index 000000000..8f2c66b46 --- /dev/null +++ b/docs/development/application/screens.md @@ -0,0 +1,322 @@ +# Screen Scenarios + +This document governs the screen tier: tests that run the whole application, draw its frames on a display, +and press its controls the way a user does. It covers `tests/screens/`, where the scenarios live, and +`tests/suite/screens/`, which drives them. Consult it before writing a scenario, and when a change to the +interface needs proving that no lower tier can give. + +--- + +## Principles + +### 1. A behavior is proven at the lowest tier that sees it + +Most behavior is proven below this tier. Logic is tested without DearPyGui, a widget on a real context +with simulated frames, and the whole application headless with its display calls stood in for. None of +them draws a frame. A screen scenario covers what only a drawn frame shows: where a control stands and +whether it fits, what a frame-lag contract decides, which scope a real key press reaches, hover, drag and +double-click, the exit and its teardown, and what a restart restores. A rule a screen depends on gets its +own case below, and the scenario keeps only what the screen adds. + +### 2. A scenario drives the application a user runs + +A scenario starts the application as `sampletones open` does and runs its own render loop unchanged. The +pointer and the keyboard are real input sent to the display, so a press travels the path a user's press +travels. A scenario reads what the screen shows and what the application wrote to disk. It reaches the +application through what the application makes public: tags, language keys, shortcuts, services and the +modal queue's snapshot. A seam a scenario needs and cannot find is added to the application as a public +one, which keeps the scenario independent of how the code is arranged inside. + +### 3. Each scenario runs in a world of its own + +Every scenario runs in a fresh process started in a home directory of its own, so settings, session state +and the documents folder hold only what the scenario put there. The process ends with the scenario, so the +exit and its teardown are part of what the scenario proves, and a crash on the way out fails it. A worker +draws on an X server of its own, so its scenarios take only its own input. + +The `world` fixture says what the home holds before the first frame: the session a previous run left, the +settings, and the recordings, documents and libraries the scenario works on. Each file is written by the +model and the manager the application reads it with, so a seeded home is one a real run could have left. A +scenario class overrides `world` to seed its own, and a folder of scenarios working on the same documents +overrides it once in its `conftest.py`. The [compatibility corpus](../../../tests/data/compatibility/README.md) +supplies the documents a release wrote, whole or damaged: restated at a version no step reaches, cut +short, or replaced by bytes of another kind. + +### 4. A scenario is written to break the application + +A scenario that only confirms the path its author had in mind proves little. Each rule a scenario proves +gets a positive example and a negative or boundary one, with real values, and each area is attacked along +the [questions](#questions-a-gui-change-asks) below. + +- **A negative ends with its witness.** After the gesture that should change nothing, the same path is used + once more where it should act, and then the whole state is asserted: the count is one lower and the row + that left is the one named. A late effect of the negative then shows in the count. Waiting a fixed + window instead would only restate the application's own delays. +- **A gesture confirms that it arrived** (see [Gestures](#gestures)), so a negative never passes because + its input was lost. +- **A frame-lag promise is read on every frame** between the gestures, since a reading once a frame until + it holds misses a flash that lasts one frame. +- **Where several states meet several doors, one suite walks the table**: every document state against + every door that closes it, every kind of broken file against every door that opens one. +- **An expected value comes from the rule**: a language key with its arguments, a palette token, or a hand + computation written in the test. A value the code under test answered proves nothing about that code. +- **A scenario that could pass vacuously is shown failing** on a fault planted for the purpose: a negative, + an attack, or a check that reads state the gesture might never reach. + +A break of something a document or a docstring states is recorded as a known bug, below, and its fix is +separate work. A break of behavior nothing states is a question for the maintainer. + +### 5. A scenario waits for what it expects + +Waiting is for a reading to hold. An expectation reads once a frame until it holds or its time runs out, +and a gesture waits for its control to be reachable and to stand still. A fixed sleep hides a race, and a +retry hides a bug: a scenario that passes only sometimes is a bug. A count of frames appears in a scenario +only where the count is the contract itself. + +A wait's time runs in seconds and in frames: the frames a machine drawing 30 a second draws in those +seconds. The wait ends once both have run, so a machine drawing slowly gives the application as many frames +as a fast one before an expectation fails. A witness that lasts a moment is read where it stays: a sound +counts once it reaches the output device, and a stopped read that must still be winding down is held there +until the scenario lets it go. + +### 6. Every scenario is held to the same promises + +Every scenario is held to the same after-checks. A failing gesture is logged and swallowed so the interface +keeps running, which is why the checks read the log as well as the screen. + +- **Quiet.** The application logged no error and no thread let an exception escape. A scenario that + provokes a failure claims the error it provokes, and an error nobody claims still fails it. A defect + the bugs ledger records, which a scenario's gestures meet by chance, is forgiven by name in the + scenarios about something else, while the ledger entry names it for its fix. +- **Contained.** The application started no program, and every file dialog it opened had an answer + waiting. +- **Settled.** No modal conversation is left open, and every window lies inside the viewport. +- **Leaving cleanly.** The application leaves through the Exit shortcut without asking anything, stops, + and writes its session state. Its background work winds down within the exit's deadline, and no thread + of its own outlives the exit. + +A known bug is a case marked `xfail(strict=True)` that names its [ledger](../bugs-and-todos.md) entry. The +mark makes the run fail once the bug is fixed, until the mark goes. A known crash is marked the same way: +the parent reports a marked scenario whose process a signal killed as the failure it expects, naming the +signal. + +### 7. The DearPyGui layer knows nothing of SampleToNES + +`tests/suite/screens/dearpygui/` drives any DearPyGui application: the process per scenario, the display, +the input, the readings and the waiting. The SampleToNES layer around it holds what belongs to this +application: the views, the stand-ins at its boundaries and the after-checks. A case in +`tests/integration/tooling/` holds the direction. The DearPyGui layer grows only when a scenario here needs +something, and it moves to a project of its own the day a second application wants it. + +## How a scenario runs + +The parent pytest run starts each scenario as a child pytest run of that one test, in a fresh interpreter. +The child writes its reports as it goes, and the parent reports them as its own, so selecting, rerunning and +strict expected failures behave as for any test. A child that crashes or runs out of time is reported as a +failure carrying its output, unless a crash is the known bug its scenario is marked with. + +Inside the child, the application is built on the main thread, where its render loop runs. The scenario +runs on a thread of its own. Every reading and every gesture crosses to the render thread through the +queue the application's own workers post to, between two frames ([the render thread](render-thread.md)). + +## Gestures + +A pointer gesture aims at the middle of a control once the control is in reach: it exists, it and every +container around it are shown, it was drawn in the last frame, it and every container around it answer a +press, its box lies inside the viewport, its middle lies in the part the regions around it leave in view, and +no modal window of another tree holds the screen. The control must also stand where it stood a frame before, +since a dialog sized by its content settles its place over its first frames ([dialogs](dialogs.md)). + +A control in a region that scrolls is brought into view the way a person brings it: the wheel turns over a +region that takes the wheel, and the grip of a region's scrollbar is dragged where the region ignores the +wheel. + +Keys are pressed on the real keyboard, with modifiers held a frame before the key. A scenario names an +action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key. + +Dear ImGui judges a press by time: two presses count as a double-click when they land within 0.30 s, and a +key held past 0.275 s repeats. A click and a key press therefore go down and come up in one go, and Dear +ImGui reads the press on one frame and the release on the next at any frame rate. A key stands down for a +single frame, too briefly to repeat, and the presses of a double-click land two frames apart. Where two +frames take longer than the double-click time, the hand raises `SlowFramesError` and says how far apart the +presses landed. + +A person lets go of a click some frames after pressing it, and DearPyGui answers a double-click as its +second press goes down. A scenario holding that press for as long as a person does sees what the answer +brings under a button still down. + +A gesture confirms that it arrived. Before a button goes down, the control must report the pointer resting +on it: DearPyGui calls a control visible while the region it scrolls in clips it, and a press there lands +on something else. While a button or a modifier is held, the application must read it as down within a +few frames, since a busy display hands input on late. A click or a key press that came and went is +confirmed by a witness, a handler registry of the hand's own that counts every release and every +double-click Dear ImGui reads. Each step of a drag waits until the application reads the pointer where the +step put it, and the pointer rests at the end before the button comes up, as a person stops before letting +go. A press lost on the way fails where it was lost. + +A worker's scenarios take its display in turn, and a key or a button left down would meet the next +scenario's first gesture. A gesture that fails lets go of what it holds, and each scenario starts and ends +with every key and button of the display up. + +A plot reads the pointer as it draws, so the value it puts under the pointer trails the pointer by a +frame. A scenario that aims by plot values reads one once the plot has kept it for two frames in a row. + +DearPyGui reports a position alone for a menu entry, so choosing one runs its callback the way a click does, +through the queue's own error reporting. + +## Views + +A scenario speaks through views, one per tab, card and dialog, under `tests/suite/screens/views/`. A view +knows its controls by their tags and its words by their language keys. A tag that moves changes one view, +and a scenario reads as the gestures a user makes. + +What only the drawn frame shows is read from it: `screen.frame_pixels()` hands over the next frame, which is +how a menu popup's width is measured. + +## The boundaries + +- **File dialogs.** A native dialog stops the queue while it stands, so a scenario queues its answer before + the gesture that opens the dialog, and the stand-in answers at once. An answer names a folder that stands + on the disk, as a native dialog's does, and a save may pick one of the file types the dialog offers by + its name. +- **Audio.** The default output device plays into silence in real time, so playback runs and nothing is + heard. A scenario can start on a machine offering no device at all. +- **Programs.** An audit hook refuses every program the application tries to start: a file manager, a + browser or a dialog tool would open on the desktop around the run. A shared library lookup passes, and + so do the stand-ins below. +- **The file manager.** Stand-in programs lead the search path: `xdg-mime` names a file manager, and that + file manager and `xdg-open` note each path they are asked to show. The application's own reveal code + runs, and `screen.revealed()` reads what it showed. +- **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request + a title bar's close button sends. +- **Table highlights.** DearPyGui holds a table's row, cell and column highlights as table state and + reports whether one stands, so the calls laying and lifting them are wrapped from the first frame on, and + `screen.table_highlights()` reads each highlight standing with its color. + +## Holding work in flight + +A gesture made while work runs is only proven if the work is provably still running when the gesture +lands. A hold keeps it there: a stand-in decides **when** the work lands and never **what** it computes. +A held conversion walks the stages a real one reports and stops halfway through matching, and a held +folder scan meets the folder's entries and then one entry after another that is no recording. Each goes +on reporting while it is held, so Stop and Cancel are heard where the real work hears them. A held rebuild +of an edited channel waits on its worker before it computes, so the edit is drawn while it is on its way, +and the real rebuild runs once it is released. A scenario standing for a rebuild that breaks lets the held +rebuilds go as that failure, which reaches the application the way a rebuild that raised does. A held +export stops at one report of its stages, after its window has heard the stage, and goes on answering a +cancel there; a scenario can move the report it stops at, to land a cancel between two files. A hold lasts +until the scenario releases it, and every hold is released before the scenario leaves, because leaving +waits for the work in flight. A scenario asks for a hold through its fixture: `conversion_hold`, +`scan_hold`, `regeneration_hold` or `export_hold`. + +## What survives a restart + +What a restart keeps is proven in two halves, each a scenario on a fresh home. In the first, the application +leaves, and the scenario reads the files it wrote through their models. In the second, the world seeds the +same value through the same model, and the scenario reads it on the screen. A writer and a reader that +disagree fail one of the halves. + +## Questions a GUI change asks + +Before a change to the interface gets its scenarios, ask which of these it meets, and provoke it in a +scenario: + +- **Render thread.** What does it touch in DearPyGui away from the render thread? +- **Teardown.** What happens when the window closes while it runs? +- **Modal over modal.** What if its dialog opens while another stands, waits or answers? +- **Frame lag.** Which geometry does it read in the frame that changes it? +- **In flight.** What if the user acts while its work is still running? +- **Unsaved work.** What does every path that replaces or closes a document ask? +- **Twice.** What does the same gesture do twice, and a double click against a single one? +- **Held press.** What does a press still down when the screen changes do to what comes under the pointer? +- **Key scope.** Which scope claims its keys: a field, a modal, a panel, a collapsed card or a rebound key? +- **Palette.** Does a live palette swap repaint it? +- **Restart.** What of it survives a restart? +- **Scale.** Does it still answer with a large folder or a long list in it? +- **Failure.** What does the user read when a file is missing, old or truncated, or a write fails? + +The list holds questions, never history. A fixed bug becomes a scenario, a step, a stand-in or an +after-check, and the list keeps only the general question that leads to it. A question joins only when no +question here would have led to the test that caught a bug, and near-duplicates merge, so the list stays +short enough to read before every change. + +A bug fix's scenario is shown failing on the code before the fix, and the pull request says so. + +## How the scenarios are laid out + +`tests/screens/` has a package per area of the application (`application`, `exports`, `instructions`, +`interface`, `main`, `prompts`, `reconstructions`, `sequencer`), and an area has a package per subject. +A subject package holds one file per responsibility: the classes in a file prove one family of rules of +that subject, and a file that grows past about 300 lines or starts answering a second question is split +by subject. A file name says its responsibility, such as `test_note_keys.py`. + +What several files of a subject share sits in modules of the package, written once two files need it: + +| Module | Holds | +|---|---| +| `constants.py` | Scalar `Final` constants and language keys | +| `cases.py` | Case dataclasses, followed by the tables built from them | +| `steps.py` | Steps, readings and the builders of a world | +| `conftest.py` | Fixtures every file of the package uses, such as `world` and `startup` | + +What several areas share lives in `tests/suite/screens/vocabulary/`, one module per area: the keys of +the dialogs and their messages, of the converter and of playback, and the names of the seeded recordings. +A value that one file alone uses stays in that file. Constants stand directly under the imports in every +module, so a reader meets the values before the code that uses them; the tables of `cases.py` follow the +types they are made of. A case in `tests/integration/tooling/` holds that order. Scenario files import from +`constants.py`, `cases.py`, `steps.py` and the vocabulary, and never from one another. + +The driver under `tests/suite/screens/` follows the same rules: a package per concern (`application`, `boundaries`, +`holds`, `plugin`, `seeds`, `worlds`, `views`, `steps`, and the DearPyGui layer with its `gestures` and `items`), +and a module per responsibility inside it. + +### Docstrings + +A docstring tells a reader what is tested, how the scenario goes and what is expected, in a few plain +sentences. + +- A test class opens with the rule it proves, told from the user's side. A second sentence gives the + logic where the method names leave it open: the setup, the gestures and the witness a negative ends with. +- A test method gets one sentence on what is expected where its name does not already say. +- A fixture says what the home holds or what opens at start. +- A step or a reading says what it does and what it leaves behind. +- A case type says what one row means, and each of its fields what it holds. +- The steps defined inside a test carry their gesture as their name and need no docstring. + +## Running and watching + +`make screens` runs every scenario across a few workers, each on an Xvfb of its own. Each worker draws +on the processor, so CI runs two of them, which leaves each application more of a small runner's processors +for its frames. `make system-deps` +installs Xvfb with the other development packages on Linux, and a missing server is named when the run +starts. The scenarios run on Linux, and [the ledger](../bugs-and-todos.md) holds the other platforms. + +To follow a scenario as it plays, draw on Xephyr, which opens its screen as a window on the desktop: + +``` +SAMPLETONES_SCREENS_DISPLAY=xephyr uv run python -m pytest tests/screens --no-cov -k display_settings +``` + +Each scenario keeps its files under `build/screens/`, in a folder named after the test: the home the +application lived in, and a screenshot of the last frame when the scenario failed. `screen.capture` keeps a +picture as evidence of a look, for a pull request rather than an assertion. + +## Who governs what + +| Concern | Owner | +|---|---| +| A scenario per process, its reports | `tests/suite/screens/dearpygui/isolation.py`, `plugin/hooks.py` | +| The fixtures a scenario asks for | `tests/suite/screens/plugin/fixtures.py`, `plugin/hold_fixtures.py` | +| The render thread crossing and waiting | `tests/suite/screens/dearpygui/bridge.py` | +| What a user can reach | `tests/suite/screens/dearpygui/reach.py`, `semantic.py` | +| The gestures | `tests/suite/screens/dearpygui/hand.py` and `gestures/`: `arrival.py` (waiting and confirming), `witness.py` (what Dear ImGui counted), `pointer.py`, `scrolling.py`, `keyboard.py` | +| What a reading says of an item | `tests/suite/screens/dearpygui/items/`: `reading.py`, `regions.py`, `texts.py`, `colors.py`, `viewport.py` | +| The display a worker draws on | `tests/suite/screens/dearpygui/display.py` | +| The application under test and how it ends | `tests/suite/screens/application/` | +| The stand-ins at the boundaries | `tests/suite/screens/boundaries/` | +| Work held in flight | `tests/suite/screens/holds/` | +| The after-checks | `tests/suite/screens/checks.py` | +| What a scenario holds and reads | `tests/suite/screens/screen.py`, `views/`, `steps/` | +| What a scenario's home holds | `tests/suite/screens/worlds/`, and the files it seeds in `seeds/` | +| The words several areas share | `tests/suite/screens/vocabulary/` | +| The window manager's requests | `tests/suite/screens/dearpygui/windows.py` | diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8dca0bdcd..a4595abdc 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -80,6 +80,10 @@ dimension the import starts carrying. `referee/test_axioms.py` does for the referees. * API documentation * Code documentation +* Screen scenarios on Windows and macOS. They run on Linux alone: a scenario's pointer and keys reach the + application through X11, and whether DearPyGui opens its window on GitHub's Windows and macOS runners is + unverified. A run there starts from a spike that opens the application on each runner and presses one + control through the callback a click runs. * What a build makes of the configuration and the session state an older one left behind. Neither has a version, so neither travels an upgrade chain or has an archived corpus. A `state.yaml` naming a panel that has since gone, or a configuration missing a setting added since, is read by whatever each loader @@ -146,3 +150,84 @@ currently out of line. An entry leaves when the code meets the contract again. moved. ## Bugs + +* Leaving the application on a machine that offers no output device fails. The shutdown saves the + session and asks the audio device manager for its current device, and with no device selected it + raises `ValueError: No audio device selected`. The screen scenario in `tests/screens/application/leaving/test_exit_shortcut.py` + reproduces it as a known failure. +* A project stating a data version no upgrade step reaches is refused by its shape, not by its version. + `ProjectContainer.load` validates the document before `_validate_document` compares the version, so + the user reads a list of validation errors instead of the version mismatch a reconstruction reports. + `tests/integration/compatibility/test_project.py` and the screen scenario in + `tests/screens/application/old_files/test_broken_projects.py` reproduce it as known failures. +* Leaving keeps the last dialog folder a run found deleted. A dialog opens in the nearest folder still + standing, while `state.yaml` goes on naming the deleted one. `tests/screens/application/restart/test_deleted_files.py` + reproduces it as a known failure. +* Leaving keeps a starred file a run found deleted: `config.yaml` goes on naming it among the favorites. + `tests/screens/application/restart/test_deleted_files.py` reproduces it as a known failure. +* A folder asked for while a stopped read winds down is dropped without a word. Stop closes the scan + window at once while the walk runs on to its next entry, and `FolderScan.start` turns away the folder + asked for in that time, though it promises that a folder asked for once the window closes is read. + `tests/screens/main/scan/test_reading_a_folder.py` reproduces it as a known failure. +* A box clicked in the Converter list leaves Source settings where it stood: ticking a channel on a + recording inside an open folder changes that row, while the card goes on naming the row picked + before, or New recordings. `tests/screens/main/row_settings/test_boxes.py` reproduces it as a known failure. +* General settings stops short of the Converter's right edge while Advanced settings is put away, + where it should fill the row and end where the Converter below it does. + `tests/screens/main/cards/test_card_layout.py` reproduces it as a known failure. +* The Destination line names no folder a run writes into once the gathered recordings convert with + different channels: it names the folder of every channel the rows use together, while each + recording goes into the folder of its own channels. `tests/screens/main/run/test_destination.py` reproduces it as + a known failure. +* A library folder pointed away from and back lists its library unloaded: the library loaded before + reads as one that exists, where it should come back loaded. + `tests/screens/main/library/test_folders_and_generators.py` reproduces it as a known failure. +* A reconstruction whose file the browser removed reads as a sample of the project: the NES frequency + field locks with the hint that the project sets its rate, though the document belongs to no + project. `tests/screens/prompts/vanished/test_reconstruction_removed.py` reproduces it as a known failure. +* A voice double-clicked with the second press still held opens edited. The double-click brings the + Reconstructions tab forward while the button is down, and the envelope graph that comes under the + pointer draws a bar for a press that began on the Voices card. `tests/screens/prompts/open_voice/test_open_voice.py` + reproduces it as a known failure. +* A Keyboard settings row reads as listening once Cancel answers the reassign question, while no key + reaches it: the keys pressed next are taken by nothing, and Escape closes the dialog. + `tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py` reproduces it as a known failure. +* Two closes before the first is answered ask twice: each close puts its question in line, so Cancel + on the first brings the second, and so does a close made twice while an edit is on its way. + `tests/screens/prompts/closing/test_over_a_question.py` and + `tests/screens/prompts/closing/test_during_an_edit.py` reproduce it as known failures. +* A reconstruction whose recording is missing draws a flat original line beside the reconstruction, + where the waveform shows the approximation on its own. `tests/screens/reconstructions/player/test_source_switch.py` + reproduces it as a known failure. +* With an instrument open on the Reconstructions tab, the note keys take Ctrl+Z, Ctrl+S and every + combination ending in a note key: the instruments panel answers a key whatever modifiers are held, so + Undo, Save and the rest never reach their shortcuts. `tests/screens/reconstructions/instruments/test_note_keys.py` + reproduces it as a known failure. +* Playing the song from a tracker row leaves the Playback menu reading Play, with Stop greyed out, while + the song plays: that path never refreshes the menu. `tests/screens/sequencer/tracker/test_notes_typed.py` reproduces + it as a known failure. +* New instrument with no project open writes into a project nobody opened: the Voices card's button has + no open-project guard, so the instrument is added and listed, while Voice ▸ New instrument stands + greyed out. `tests/screens/sequencer/voices/test_voices_card.py` reproduces it as a known failure. +* The history lines of a renamed or a moved voice name it one way alone: a rename names the voice and + not its position, and a move names the positions and not the voice. + `tests/screens/sequencer/history/test_voice_gestures.py` reproduces it as a known failure. +* A project opened as the application starts is saved as though it had no file: `load_project_safely` + leaves the session's current project unset, so Save asks for a path, or writes to whatever path an + earlier session left. `tests/screens/sequencer/song/test_retuning_and_saving.py` reproduces it as a known failure. +* A voice row's hover can log an error: the Voices list rebuilds every row on each update, and a hover + callback queued for a row before the rebuild reads an item that no longer exists + (`_on_row_hovered`, "Item not found"). The Sequencer's screen scenarios forgive it by name; no + scenario reproduces it on demand, since it rests on the order the queued callbacks run in. +* Export instrument... in a project whose samples were converted at two tunings does nothing the user + can see: `voice_instrument` raises the tuning error inside the menu's callback, so no message, no save + dialog and no file follow, while a Bitphase project or an NSF program of the same project stops with a + message. `tests/screens/exports/progress/test_refusals.py` reproduces it as a known failure. +* Closing the window while Display settings holds a window size kept on the countdown but never confirmed + writes that size: leaving records the live window size, while the dialog keeps the session at the values + it opened with until OK, and the window manager's close passes the open dialog by. + `tests/screens/interface/display/test_kept_size_at_close.py` reproduces it as a known failure. +* Closing the window while a folder is being read crashes the process once the read ends: nothing stops the + walk on exit, so it runs past the shutdown, and its report closes the reading window through DearPyGui + after the context is gone (SIGSEGV). `tests/screens/application/closing/test_during_work.py` reproduces it as a + known failure. diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 7df38780e..5383e33f0 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -90,6 +90,7 @@ These rules govern the Python in this repository. They complement 1. A test file mirrors the ownership of the code it exercises. 1. When functionality moves between packages, move its direct unit tests in the same change. +1. **What only a drawn frame shows is proven by a screen scenario.** A behavior is proven at the lowest tier that sees its outcome, and a scenario in `tests/screens/` covers what a running, rendered application adds: geometry, frame timing, real input and the exit. `make screens` runs the scenarios, and [screen scenarios](application/screens.md) says how they are written. 1. **A test whose assertion is a measured duration lives in `tests/benchmarks/`.** The gated suite runs across several workers and under coverage, which multiplies the cost of the code being measured. Benchmarks run in a pass of their own, serial and uncovered, where the reading is the code's own cost. `make test` runs the covered suite, and `make benchmarks` runs the measured pass. 1. Parametrize tests that share a body, using a test-case dataclass. 1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. diff --git a/docs/index.md b/docs/index.md index f8a39a680..5a15168b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -87,6 +87,7 @@ whole repository. The pages about the graphical application and about releases e - [Reconstruction browser](development/application/browser.md) — how a reconstructions directory becomes the tree both browser tabs show, and what narrows it. - [Stems in the application](development/application/stems.md) — the Stems card, and what an edit or a removal does to the per-frame record. - [Editing the open reconstruction](development/application/reconstruction-edits.md) — how the Reconstructions tab takes the reader's edits one step at a time, and what waits for them. +- [Screen scenarios](development/application/screens.md) — tests that run the whole application on a display and press its controls the way a user does. - [Configuration](development/application/config-organization.md) — how the YAML configuration package is laid out. ### Releases diff --git a/pyproject.toml b/pyproject.toml index 01b240ac7..0adf04fc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,10 @@ dev = [ "pytest==9.1.1", "pytest-cov==7.1.0", "pytest-xdist==3.8.0", + "python-xlib==0.33", + "pyvirtualdisplay==3.0", "types-PyYAML==6.0.12.20260518", + "types-python-xlib==0.33.0.20260724", ] [build-system] diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py index 68f1acf1e..4be482b5e 100644 --- a/scripts/bootstrap/layout.py +++ b/scripts/bootstrap/layout.py @@ -5,6 +5,7 @@ PROJECT_FILE: Final[str] = "pyproject.toml" SOURCE_DIRECTORY: Final[str] = "src" BENCHMARKS_DIRECTORY: Final[str] = "tests/benchmarks" +SCREENS_DIRECTORY: Final[str] = "tests/screens" DISTRIBUTION: Final[str] = "bin" BUNDLES: Final[str] = "bundles" BUILD_ENVIRONMENT: Final[str] = ".venv-build" diff --git a/scripts/bootstrap/platforms/linux.py b/scripts/bootstrap/platforms/linux.py index 2c0691ac2..4a1a0ac4d 100644 --- a/scripts/bootstrap/platforms/linux.py +++ b/scripts/bootstrap/platforms/linux.py @@ -24,6 +24,8 @@ "libxrandr2", "libxrender1", "libxxf86vm1", + "libgl1-mesa-dri", + "xvfb", ) BUNDLING: Final[Bundling] = Bundling( icon=PNG_ICON, diff --git a/scripts/run_tests.py b/scripts/run_tests.py index f1d213741..d4d9b8989 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -3,27 +3,34 @@ import sys from typing import Dict, Final, Sequence, Tuple -from bootstrap.layout import BENCHMARKS_DIRECTORY, SOURCE_DIRECTORY, repository_root +from bootstrap.layout import BENCHMARKS_DIRECTORY, SCREENS_DIRECTORY, SOURCE_DIRECTORY, repository_root from bootstrap.passes import Pass, run_pass from bootstrap.processes import run SUITE: Final[str] = "suite" DOCTESTS: Final[str] = "doctests" BENCHMARKS: Final[str] = "benchmarks" +SCREENS: Final[str] = "screens" DEFAULT_WORKERS: Final[str] = "6" +SCREEN_WORKERS: Final[str] = "4" PYTEST: Final[Tuple[str, ...]] = ("uv", "run", "python", "-m", "pytest") -def planned_passes(workers: str) -> Dict[str, Pass]: - """The passes a test run is made of, by name: the covered suite, the doctests, the benchmarks. +def planned_passes(workers: str, screen_workers: str) -> Dict[str, Pass]: + """The passes a test run is made of, by name: the covered suite, the doctests, the benchmarks + and the screen scenarios. Each pass is a target and a hook of its own, so a failure names the pass it belongs to. The covered suite runs across ``workers`` pytest workers. The benchmarks run serial, uncovered and with their output shown, so a measured duration is the code's own cost and its reading - reaches the terminal. + reaches the terminal. The screen scenarios drive the running application on a display of + each worker's own, each scenario in a process of its own, so they run uncovered across + ``screen_workers`` workers, each drawing its frames on the processor. Args: workers: The worker count for the covered suite, or ``auto`` for one per processor. + screen_workers: The worker count for the screen scenarios, each worker drawing one + application at a time. Returns: Dict[str, Pass]: Every pass, under its name. @@ -32,7 +39,14 @@ def planned_passes(workers: str) -> Dict[str, Pass]: Pass( SUITE, "Running pytest with coverage...", - (*PYTEST, "-n", workers, "--cov", f"--ignore={BENCHMARKS_DIRECTORY}"), + ( + *PYTEST, + "-n", + workers, + "--cov", + f"--ignore={BENCHMARKS_DIRECTORY}", + f"--ignore={SCREENS_DIRECTORY}", + ), ), Pass( DOCTESTS, @@ -44,6 +58,11 @@ def planned_passes(workers: str) -> Dict[str, Pass]: "Running benchmarks...", (*PYTEST, BENCHMARKS_DIRECTORY, "--no-cov", "-s"), ), + Pass( + SCREENS, + "Running screen scenarios...", + (*PYTEST, SCREENS_DIRECTORY, "-n", screen_workers, "--no-cov"), + ), ) return {current.name: current for current in passes} @@ -51,16 +70,25 @@ def planned_passes(workers: str) -> Dict[str, Pass]: def main(argv: Sequence[str]) -> int: """Runs one pass of the tests and exits with the status pytest gave it.""" parser = argparse.ArgumentParser(description="Run one pass of the SampleToNES tests.") - parser.add_argument("name", choices=tuple(planned_passes(DEFAULT_WORKERS)), help="the pass to run") + parser.add_argument( + "name", + choices=tuple(planned_passes(DEFAULT_WORKERS, SCREEN_WORKERS)), + help="the pass to run", + ) parser.add_argument( "--workers", default=DEFAULT_WORKERS, help="pytest workers for the covered suite: a count, or auto for one per processor", ) + parser.add_argument( + "--screen-workers", + default=SCREEN_WORKERS, + help="pytest workers for the screen scenarios, each drawing one application at a time", + ) arguments = parser.parse_args(list(argv)) return run_pass( - planned_passes(arguments.workers)[arguments.name], + planned_passes(arguments.workers, arguments.screen_workers)[arguments.name], root=repository_root(), runner=run, environment=os.environ, diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 4c135937e..a74d41077 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -500,10 +500,19 @@ def _create_hover_callback( self, status_bar_callback: Optional[MessageCallback], ) -> Callback: + """The hover callback of a row, which names the row in the status bar and its detail tooltip. + + The hover is reported a frame after it happened, by which time a rebuilt tree may have taken + the row away, so the callback answers for the rows still standing. + """ + def hover_callback( _sender: Sender, app_data: int, ) -> None: + if not dpg.does_item_exist(app_data): + return + user_data = dpg.get_item_user_data(app_data) if status_bar_callback is not None: self._status_bar.set(status_bar_callback, user_data=user_data) diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 15de17f9d..06670d7fe 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -145,8 +145,13 @@ def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: """Says what the hovered row holds, which the id cell and the name cell both address. Both cells carry the row's position and its voice, so one handler covers the whole row and - a reader reads the same line wherever the pointer rests on it. + a reader reads the same line wherever the pointer rests on it. The hover is reported a frame + after it happened, by which time a rebuilt list may have taken the cell away, so the handler + answers for the cells still standing. """ + if not dpg.does_item_exist(app_data): + return + user_data = dpg.get_item_user_data(app_data) if not isinstance(user_data, tuple): return diff --git a/src/sampletones_application/utils/callbacks/queue.py b/src/sampletones_application/utils/callbacks/queue.py index d8c2d6e4c..6a20293c0 100644 --- a/src/sampletones_application/utils/callbacks/queue.py +++ b/src/sampletones_application/utils/callbacks/queue.py @@ -74,6 +74,12 @@ def notify_frame(cls) -> None: with cls._lock: cls._frame_counter += 1 + @classmethod + def current_frame(cls) -> int: + """The frame the counter stands at, which a delay given to :meth:`add` counts from.""" + with cls._lock: + return cls._frame_counter + @classmethod def process(cls, budget_seconds: float) -> None: """Run the callbacks due at the current frame, up to a time budget. diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py index 38dac23b2..f503d6f13 100644 --- a/src/sampletones_application/utils/gui/modal_queue.py +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -1,6 +1,6 @@ from collections import deque from dataclasses import dataclass -from typing import ClassVar, Deque, Dict, List, Optional +from typing import ClassVar, Deque, Dict, List, Optional, Tuple from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.meta import NonInstantiableMeta @@ -20,6 +20,28 @@ class WaitingModal: build: VoidCallback +@dataclass(frozen=True) +class ModalQueueSnapshot: + """What the line holds at one moment, for a reader watching the screen from outside it. + + Attributes: + shown: The window standing on the screen, or ``None`` while the screen is free. + aside: The windows standing aside for the modals they handed the screen to. + waiting: The windows waiting for the screen, in the order they open. + turning: Whether the line moves on in a coming frame. + """ + + shown: Optional[str] + aside: Tuple[str, ...] + waiting: Tuple[str, ...] + turning: bool + + @property + def is_settled(self) -> bool: + """Whether the line stands still: nothing waits, nothing stands aside, and no turn is due.""" + return not self.aside and not self.waiting and not self.turning + + class ModalQueue(metaclass=NonInstantiableMeta): """The one modal window DearPyGui shows at a time, and the modal windows waiting for the screen. @@ -117,6 +139,16 @@ def hand_off(cls, continuation: VoidCallback) -> None: cls._hand_offs.append(continuation) cls._take_a_turn() + @classmethod + def snapshot(cls) -> ModalQueueSnapshot: + """What the line holds now: the window standing, the ones aside and the ones waiting.""" + return ModalQueueSnapshot( + shown=cls._shown, + aside=tuple(cls._aside), + waiting=tuple(waiting.tag for waiting in cls._waiting), + turning=cls._turn_due, + ) + @classmethod def clear(cls) -> None: """Forgets every window, which is where a DearPyGui context that was just made starts from.""" diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 9cb137288..22ad15516 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -40,6 +40,10 @@ def register(self, shortcut_id: ShortcutId, callback: Callback) -> None: """Names the call an action makes when its combination is pressed or its menu item chosen.""" self._callbacks[shortcut_id] = callback + def shortcut(self, shortcut_id: ShortcutId) -> Shortcut: + """The binding an action answers to under the scheme in place, rebinds included.""" + return self._source.shortcut(shortcut_id) + def add_menu_item( self, shortcut_id: ShortcutId, diff --git a/tests/integration/compatibility/test_project.py b/tests/integration/compatibility/test_project.py index e4d34913e..3f48a2836 100644 --- a/tests/integration/compatibility/test_project.py +++ b/tests/integration/compatibility/test_project.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Final, List import pytest @@ -15,7 +16,8 @@ SAMPLETONES_PROJECT_DATA_VERSION, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) -from tests.suite.compatibility import PROJECT_VERSION, archived, stored_version +from sampletones_shared.exceptions.project import IncompatibleProjectVersionError, InvalidProjectDataValuesError +from tests.suite.compatibility import PROJECT_VERSION, archived, restated_document, stored_version VOICE_NAMES: Final[List[str]] = ["kick", "kick again"] PATTERN_NAME: Final[str] = "verse" @@ -27,6 +29,8 @@ PROJECT_TEMPO: Final[int] = 132 ROW_TRANSPOSE: Final[int] = 3 ROW_VOLUME: Final[int] = 9 +UNREACHED_VERSION: Final[str] = "0.9" +UNREACHED_FILENAME: Final[str] = "unreached.stp" @pytest.fixture(name="loaded") @@ -55,6 +59,29 @@ def test_it_opens_all_the_same(self, loaded: Project) -> None: assert loaded.voices +class TestAVersionNoStepReaches: + """A document stating a version no chain reaches is refused by the load contract, which names both versions.""" + + @pytest.mark.xfail( + strict=True, + raises=InvalidProjectDataValuesError, + reason="bugs-and-todos § Bugs: a project at a version no step reaches is refused by its shape", + ) + def test_a_load_refuses_it_naming_both_versions(self, tmp_path: Path) -> None: + path = tmp_path / UNREACHED_FILENAME + path.write_bytes( + restated_document(archived(ObjectKind.PROJECT, PROJECT_VERSION), ObjectKind.PROJECT, UNREACHED_VERSION) + ) + + with pytest.raises(IncompatibleProjectVersionError) as refused: + ProjectContainer.load(path) + + assert (refused.value.actual_version, refused.value.expected_version) == ( + UNREACHED_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + ) + + class TestTheVoicesAnUpgradedProjectHolds: """What a 1.0 document called its samples are the voices a project holds now.""" diff --git a/tests/integration/tooling/test_screen_layers.py b/tests/integration/tooling/test_screen_layers.py new file mode 100644 index 000000000..d8b6c7d83 --- /dev/null +++ b/tests/integration/tooling/test_screen_layers.py @@ -0,0 +1,50 @@ +import ast +import sys +from pathlib import Path +from typing import Final, FrozenSet, Iterator + +from tests.suite.screens.paths import DEARPYGUI_LAYER_DIRECTORY + +LAYER_PACKAGE: Final[str] = "tests.suite.screens.dearpygui" +MODULE_SEPARATOR: Final[str] = "." +THIRD_PARTY: Final[FrozenSet[str]] = frozenset({"dearpygui", "pytest", "pyvirtualdisplay", "Xlib"}) + + +def imported_modules(path: Path) -> Iterator[str]: + """Every module ``path`` imports by its absolute name.""" + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + yield from (alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None: + yield node.module + + +def is_allowed(module: str) -> bool: + top = module.split(MODULE_SEPARATOR, 1)[0] + return ( + top in sys.stdlib_module_names + or top in THIRD_PARTY + or module == LAYER_PACKAGE + or module.startswith(LAYER_PACKAGE + MODULE_SEPARATOR) + ) + + +class TestTheDearPyGuiLayer: + """The screen tier's DearPyGui layer knows DearPyGui and pytest, and nothing of SampleToNES. + + The layer drives any DearPyGui application, and SampleToNES builds on it from outside, so the + layer moves to a project of its own as it stands the day a second application wants it. + """ + + def test_every_import_is_dearpygui_pytest_x11_or_the_standard_library(self) -> None: + strays = [ + f"{path.name} imports {module}" + for path in sorted(DEARPYGUI_LAYER_DIRECTORY.rglob("*.py")) + for module in imported_modules(path) + if not is_allowed(module) + ] + + assert strays == [] + + def test_the_layer_holds_modules_to_read(self) -> None: + assert any(DEARPYGUI_LAYER_DIRECTORY.rglob("*.py")) diff --git a/tests/integration/tooling/test_screen_layout.py b/tests/integration/tooling/test_screen_layout.py new file mode 100644 index 000000000..59107b496 --- /dev/null +++ b/tests/integration/tooling/test_screen_layout.py @@ -0,0 +1,67 @@ +import ast +from pathlib import Path +from typing import Final, Iterator, List + +from sampletones_shared.paths.source import REPOSITORY_ROOT +from tests.suite.screens.paths import SCREEN_DRIVER_DIRECTORY, SCREENS_DIRECTORY + +SCENARIO_FILE_PREFIX: Final[str] = "test_" +CASES_MODULE: Final[str] = "cases.py" +SCREEN_TIER_DIRECTORIES: Final[List[Path]] = [SCREENS_DIRECTORY, SCREEN_DRIVER_DIRECTORY] + + +def screen_tier_modules() -> Iterator[Path]: + for directory in SCREEN_TIER_DIRECTORIES: + yield from sorted(directory.rglob("*.py")) + + +def relative(path: Path) -> str: + return path.relative_to(REPOSITORY_ROOT).as_posix() + + +def constants_after_code(path: Path) -> List[int]: + """The lines of module-level assignments that follow the module's first function or class.""" + lines: List[int] = [] + code_started = False + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + code_started = True + elif code_started and isinstance(node, (ast.Assign, ast.AnnAssign)): + lines.append(node.lineno) + + return lines + + +def imported_scenario_modules(path: Path) -> Iterator[str]: + """Every scenario file (``test_*.py``) that ``path`` imports by its absolute name.""" + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None: + if node.module.rsplit(".", 1)[-1].startswith(SCENARIO_FILE_PREFIX): + yield node.module + + +class TestTheScreenTierLayout: + """The screen tier's modules read the same way: constants under the imports, packages that import, scenarios that stand alone.""" + + def test_constants_stand_under_the_imports(self) -> None: + offenders = { + relative(path): constants_after_code(path) + for path in screen_tier_modules() + if path.name != CASES_MODULE and constants_after_code(path) + } + + assert offenders == {} + + def test_every_folder_of_modules_is_a_package(self) -> None: + folders = {path.parent for path in screen_tier_modules()} + + assert [relative(folder) for folder in sorted(folders) if not (folder / "__init__.py").exists()] == [] + + def test_no_module_imports_a_scenario_file(self) -> None: + importers = { + relative(path): list(imported_scenario_modules(path)) + for path in screen_tier_modules() + if list(imported_scenario_modules(path)) + } + + assert importers == {} diff --git a/tests/screens/__init__.py b/tests/screens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/__init__.py b/tests/screens/application/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/closing/__init__.py b/tests/screens/application/closing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/closing/constants.py b/tests/screens/application/closing/constants.py new file mode 100644 index 000000000..8ac5b6dd2 --- /dev/null +++ b/tests/screens/application/closing/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +NEW_TITLE: Final[str] = "Closing title" +CONVERSION_RUNNING: Final[str] = "global.dialog.message.exit_conversion_in_progress" +RUN_TIMEOUT_SECONDS: Final[float] = 60.0 diff --git a/tests/screens/application/closing/steps.py b/tests/screens/application/closing/steps.py new file mode 100644 index 000000000..adf339c6b --- /dev/null +++ b/tests/screens/application/closing/steps.py @@ -0,0 +1,14 @@ +from tests.screens.application.closing.constants import CONVERSION_RUNNING +from tests.suite.screens.screen import Screen +from tests.suite.screens.views.prompts import Prompt + + +def conversion_question(screen: Screen) -> Prompt: + """The prompt Exit shows while a conversion runs, in the window the reconstruction's question uses.""" + return screen.reconstructions.unsaved_prompt + + +def asks_about_the_conversion(screen: Screen) -> bool: + """Tells whether the shared prompt is shown with the words about a conversion in progress.""" + prompt = conversion_question(screen) + return prompt.is_shown() and screen.words(CONVERSION_RUNNING) in prompt.words() diff --git a/tests/screens/application/closing/test_during_work.py b/tests/screens/application/closing/test_during_work.py new file mode 100644 index 000000000..773cb61ac --- /dev/null +++ b/tests/screens/application/closing/test_during_work.py @@ -0,0 +1,270 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.extensions import EXT_FILE_MODULE +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.closing.constants import RUN_TIMEOUT_SECONDS +from tests.screens.application.closing.steps import asks_about_the_conversion, conversion_question +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.holds.export import ExportHold +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.holds.scan import ScanHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.steps.reconstructions import expect_open, raise_the_first_level_while_held +from tests.suite.screens.vocabulary.playback import PAUSE +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import ( + BASS, + OPEN_RECONSTRUCTION, + PLAYABLE_RECONSTRUCTION, + documents_world, + lived_in_world, + playing_world, +) +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world +from tests.suite.screens.written import written_state + +SETTLING_FRAMES: Final[int] = 30 +FOLDER: Final[str] = "Many" +FOLDER_RECORDINGS: Final[int] = 130 +RECORDING_SECONDS: Final[float] = 0.05 +RECORDING_FREQUENCY: Final[float] = 330.0 + + +def close_and_leave(screen: Screen, tab: Tab) -> None: + """Closes the window with ``tab`` in front, waits for the application to stop, and checks the session + written. + """ + screen.close_window() + + assert screen.wait_for_exit() + assert written_state().current.tab is tab + + +class TestClosingWhilePlaying: + """Closing the window while a reconstruction plays leaves at once and writes the session. + + A playable reconstruction is open at start. The scenario presses Play, waits for the Play entry to read + Pause, brings the Instructions tab forward, closes the window, and expects the application to stop with + Instructions written as the tab in front. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a playable reconstruction and a session ready for playback.""" + return playing_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the playable reconstruction at start.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_it_leaves_while_the_sound_goes_on(self, screen: Screen) -> None: + """The window closes at once and the session names the tab in front.""" + + def play(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.INSTRUCTIONS) + + screen.scenario(play, close).run() + + +class TestClosingDuringAConversion: + """Closing the window while a conversion runs asks first: Cancel keeps both going, Exit stops the run and + leaves. + + The scenario gathers a recording, presses the converter's action, and waits for progress. The first + close asks about the run; Cancel takes the question back and leaves the run going. The second close + asks again; Exit stops the run and leaves, and the session is written with the Main tab in front and no + reconstruction in the reconstructions folder. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world: recordings to gather and stored documents.""" + return documents_world() + + def test_it_asks_and_then_stops_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Cancel keeps the run and the application; Exit leaves with the run's output absent.""" + converter = screen.main.converter + prompt = conversion_question(screen) + + def start_a_held_run(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def cancel_keeps_it_running(screen: Screen) -> None: + screen.close_window() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert converter.progress() > 0 + + def exit_stops_it_and_leaves(screen: Screen) -> None: + screen.close_window() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question again") + + prompt.confirm() + + assert screen.wait_for_exit() + assert written_state().current.tab is Tab.MAIN + assert not list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{home_path(BASS).stem}.*")) + + screen.scenario(start_a_held_run, cancel_keeps_it_running, exit_stops_it_and_leaves).run() + + +class TestClosingDuringAFolderRead: + """Closing the window while a folder is read leaves, the read given up, and writes the session. + + The home holds a folder of many short recordings. The scenario Ctrl-clicks the folder to start a read, + waits until the converter shows the scan, closes the window, and expects the application to stop with + the Main tab written. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a lived-in session and a folder of many short recordings.""" + lived_in = lived_in_world() + return World( + state=lived_in.state, + application_config=None, + config=None, + files=tuple( + Recording( + destination=home_path(FOLDER) / f"take{index:03d}.wav", + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + for index in range(FOLDER_RECORDINGS) + ), + ) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a folder read outliving the exit calls DearPyGui after its context is gone", + ) + def test_it_leaves_the_read_behind(self, screen: Screen, scan_hold: ScanHold) -> None: + """The application stops after the close while the folder read is in progress.""" + converter = screen.main.converter + + def start_reading(screen: Screen) -> None: + row = screen.expect_item( + lambda: screen.explorer.file_row(home_path(FOLDER)), + description="the folder's row", + ) + screen.explorer.ctrl_click(row) + screen.expect(converter.scan_shown, bool, description="the folder being read") + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.MAIN) + + screen.scenario(start_reading, close).run() + + +class TestClosingDuringAnExport: + """Closing the window while an export runs leaves, the export stopped with no file written. + + An arranged project is open. The scenario starts a FamiTracker export whose writing is held, closes the + window, and expects the application to stop with the Sequencer tab written and the export's destination + absent. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a Sequencer world with an arranged project.""" + return sequencer_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the arranged project at start.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_it_leaves_and_writes_nothing(self, screen: Screen, export_hold: ExportHold) -> None: + """The application stops and the export's destination stays absent.""" + destination = PROJECTS_DIRECTORY / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_a_held_export(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.SAVE, destination) + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + screen.expect(screen.exports.progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.SEQUENCER) + + assert not destination.exists() + + screen.scenario(start_a_held_export, close).run() + + +class TestClosingDuringARebuild: + """Closing the window while an edit is on its way waits for it to land, then asks about the edited + document. + + An open reconstruction receives an edit whose regeneration is held. The first close keeps the + application running with no question. Once the edit lands, the question about the edited reconstruction + appears; confirming it leaves. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world with a reconstruction ready to open.""" + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the reconstruction at start.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_it_waits_and_then_asks(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The close waits for the held edit, then the unsaved reconstruction prompt appears.""" + prompt = screen.reconstructions.unsaved_prompt + + def edit_while_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_waits_for_the_edit(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert not prompt.is_shown() + + def the_edit_lands_and_the_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about the edited reconstruction") + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(edit_while_held, close_waits_for_the_edit, the_edit_lands_and_the_question_comes).run() diff --git a/tests/screens/application/closing/test_exit_chain.py b/tests/screens/application/closing/test_exit_chain.py new file mode 100644 index 000000000..ebb41ad96 --- /dev/null +++ b/tests/screens/application/closing/test_exit_chain.py @@ -0,0 +1,88 @@ +from typing import List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.application.closing.constants import NEW_TITLE, RUN_TIMEOUT_SECONDS +from tests.screens.application.closing.steps import asks_about_the_conversion, conversion_question +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.steps.project import retitle_project +from tests.suite.screens.steps.reconstructions import expect_open, raise_the_first_level_while_held +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import BASS, OPEN_RECONSTRUCTION, SONG, documents_world + + +class TestExitWithEverythingPending: + """Exit with an unsaved project, an unsaved reconstruction and a conversion running asks about each, in + that order, and leaves once the last is answered. + + The scenario retitles the project, edits the reconstruction, and starts a held conversion. Exit then + asks about the project, the reconstruction and the conversion in turn; each is confirmed, and the + application stops. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world: a project, a reconstruction and recordings.""" + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the reconstruction and the project at start.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_questions_come_in_order( + self, + screen: Screen, + conversion_hold: ConversionHold, + regeneration_hold: RegenerationHold, + ) -> None: + """The three questions appear as project, reconstruction, conversion.""" + converter = screen.main.converter + asked: List[str] = [] + + def change_both_documents_and_start_a_run(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + retitle_project(screen, NEW_TITLE) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + regeneration_hold.release() + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def answer_each_question(screen: Screen) -> None: + project = screen.project.unsaved_prompt + reconstruction = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project.is_shown, bool, description="the question about the project") + asked.append("project") + project.confirm() + screen.expect( + lambda: reconstruction.is_shown() and not asks_about_the_conversion(screen), + bool, + description="the question about the reconstruction", + ) + asked.append("reconstruction") + reconstruction.confirm() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + asked.append("conversion") + conversion_question(screen).confirm() + + assert screen.wait_for_exit() + assert asked == ["project", "reconstruction", "conversion"] + + screen.scenario(change_both_documents_and_start_a_run, answer_each_question).run() diff --git a/tests/screens/application/closing/test_idle_and_unsaved.py b/tests/screens/application/closing/test_idle_and_unsaved.py new file mode 100644 index 000000000..599b6179b --- /dev/null +++ b/tests/screens/application/closing/test_idle_and_unsaved.py @@ -0,0 +1,101 @@ +import operator +from typing import Final + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.application.closing.constants import NEW_TITLE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title + +PROJECT_FILENAME: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" + + +def save_a_titled_project(screen: Screen) -> None: + """Creates a project, gives it a title, and saves it in the projects folder.""" + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +def change_the_title(screen: Screen) -> None: + """Retitles the open project, leaving a change to save.""" + retitle_project(screen, NEW_TITLE) + + +def close_asks_first(screen: Screen) -> None: + """Closes the window and waits for the unsaved project prompt.""" + screen.close_window() + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + """Reads the title stored in the saved project file.""" + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +class TestClosingTheWindow: + """The close button on the window's title bar leaves the application the way Exit does.""" + + def test_an_application_with_nothing_unsaved_leaves_at_once(self, screen: Screen) -> None: + """The window closes and the application stops at once.""" + screen.close_window() + + assert screen.wait_for_exit() + + +class TestClosingTheWindowOverAnUnsavedProject: + """Closing the window with an unsaved project asks first, and asks again at each close. + + A saved project is retitled and the window closed; Save writes the new title and leaves, Cancel keeps + the file and the application, and the next close asks once more with one prompt on screen. Exit then + discards the change and leaves, with the file still holding the saved title. + """ + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_and_the_file_and_the_next_close_asks_again(self, screen: Screen) -> None: + """The file keeps its saved title through Cancel, the second prompt and the discarding exit.""" + prompt = screen.project.unsaved_prompt + + def cancel(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the prompt gone") + + def close_again(screen: Screen) -> None: + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the unsaved project prompt again") + assert len(prompt.shown_windows()) == 1 + assert screen.is_running() + assert saved_title() == SAVED_TITLE + + def discard_and_leave(screen: Screen) -> None: + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() == SAVED_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + cancel, + close_again, + discard_and_leave, + ).run() diff --git a/tests/screens/application/leaving/__init__.py b/tests/screens/application/leaving/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/leaving/test_exit_shortcut.py b/tests/screens/application/leaving/test_exit_shortcut.py new file mode 100644 index 000000000..7a847bd31 --- /dev/null +++ b/tests/screens/application/leaving/test_exit_shortcut.py @@ -0,0 +1,135 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.boundaries.audio import OutputDevice +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, save_new_project, saved_project_title + +PROJECT_FILENAME: Final[str] = "Leaving.stp" +NEW_TITLE: Final[str] = "Leaving title" +SETTLING_FRAMES: Final[int] = 10 + + +def save_a_new_project(screen: Screen) -> None: + """Creates a project and saves it in the projects folder.""" + save_new_project(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +def retitle_the_project(screen: Screen) -> None: + """Retitles the open project, leaving a change to save.""" + retitle_project(screen, NEW_TITLE) + + +def ask_to_leave(screen: Screen) -> None: + """Presses the Exit shortcut and waits for the unsaved project prompt.""" + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + """Reads the title stored in the saved project file.""" + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +class TestLeavingWithAnUnsavedProject: + """Leaving the application with changes to a saved project asks about them first. + + A new project is saved and retitled, and the Exit shortcut pressed. Save writes the new title and + leaves. Cancel keeps the application open with the file as it was; a second Exit and a confirmation + then leave with the file still holding the earlier title. + """ + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_open_and_the_change_unsaved(self, screen: Screen) -> None: + """The application keeps running after Cancel and the file keeps its earlier title.""" + + def cancel(screen: Screen) -> None: + screen.project.unsaved_prompt.cancel() + + screen.expect(screen.project.unsaved_prompt.is_shown, operator.not_, description="the prompt gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert saved_title() != NEW_TITLE + + def leave_without_saving(screen: Screen) -> None: + ask_to_leave(screen) + + screen.project.unsaved_prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() != NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + cancel, + leave_without_saving, + ).run() + + +class TestLeavingFromTheKeyboard: + """The exit's questions answer to the keys alone, from the first press to the last. + + After the Exit shortcut brings up the question, the next-control key and the activate key choose Save, + write the new title and leave. + """ + + def test_tab_and_enter_save_the_change_and_leave(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_from_the_keyboard(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + save_from_the_keyboard, + ).run() + + +class TestLeavingWhereNothingCanPlay: + """A machine offering no output device opens the application and leaves it cleanly. + + The scenario waits for the Main tab and then ends the test, which closes the application. + """ + + @pytest.fixture + def output_device(self) -> OutputDevice: + """The machine offers no output device.""" + return OutputDevice.NONE + + @pytest.mark.xfail( + strict=True, + raises=ValueError, + reason="bugs-and-todos § Bugs: leaving the application where nothing can play", + ) + def test_the_application_leaves_without_an_error(self, screen: Screen) -> None: + """The application starts and stops cleanly with no output device.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") diff --git a/tests/screens/application/old_files/__init__.py b/tests/screens/application/old_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/old_files/cases.py b/tests/screens/application/old_files/cases.py new file mode 100644 index 000000000..46babb0de --- /dev/null +++ b/tests/screens/application/old_files/cases.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +from tests.screens.application.old_files.constants import VANISHED +from tests.screens.application.old_files.steps import damaged_name +from tests.suite.screens.seeds.archives import Damage + + +@dataclass(frozen=True) +class StartupCase: + """One row of a startup table: a broken document handed over at start. + + Attributes: damage: The damage done to the file, or None for a file that is gone. + """ + + damage: Optional[Damage] + + def name(self, suffix: str) -> str: + """The file name of the row's document with ``suffix``: the damage's name, or the vanished file's.""" + return damaged_name(self.damage, suffix) if self.damage is not None else f"{VANISHED}{suffix}" + + +STARTUP_CASES: Final[Tuple[StartupCase, ...]] = (*(StartupCase(damage) for damage in Damage), StartupCase(None)) diff --git a/tests/screens/application/old_files/constants.py b/tests/screens/application/old_files/constants.py new file mode 100644 index 000000000..fe880b416 --- /dev/null +++ b/tests/screens/application/old_files/constants.py @@ -0,0 +1,11 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY + +ARCHIVED_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Archived.stn" +ARCHIVED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Archived.stp" +VANISHED: Final[str] = "vanished" +SAMPLES_FIELD: Final[str] = "samples" +NAME_FIELD: Final[str] = "name" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" diff --git a/tests/screens/application/old_files/steps.py b/tests/screens/application/old_files/steps.py new file mode 100644 index 000000000..1dcc6f774 --- /dev/null +++ b/tests/screens/application/old_files/steps.py @@ -0,0 +1,48 @@ +from typing import List + +from sampletones_core.compatibility.kind import ObjectKind +from tests.screens.application.old_files.constants import NAME_FIELD, SAMPLES_FIELD +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import Damage +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.worlds.home import HomeFile, World, screen_filling_state + + +def future_version(current: str) -> str: + """A data version one major step past ``current``.""" + major, _ = current.split(".", 1) + return f"{int(major) + 1}.0" + + +def stored_voice_names() -> List[str]: + """The voice names the archived project stores, read off its document without the application's model.""" + document = stored_document(archived(ObjectKind.PROJECT, ARCHIVED_VERSIONS[ObjectKind.PROJECT])) + return [sample[NAME_FIELD] for sample in document[SAMPLES_FIELD]] + + +def damaged_name(damage: Damage, suffix: str) -> str: + """The file name a document with ``damage`` takes, ending in ``suffix``.""" + return f"{damage.name.lower()}{suffix}" + + +def stated_version(damage: Damage, current: str, older: str) -> str: + """The version the application states when refusing a file with ``damage``: ``older`` or a future one.""" + return older if damage is Damage.OLDER_VERSION else future_version(current) + + +def world_of(*files: HomeFile) -> World: + """A home with a screen-filling session and ``files``.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=files, + ) + + +def shown_notice(screen: Screen) -> Notice: + """Waits for an error or a file-not-found notice and returns the one shown.""" + notices = (screen.error_notice, screen.file_not_found_notice) + screen.expect(lambda: any(notice.is_shown() for notice in notices), bool, description="a notice") + return next(notice for notice in notices if notice.is_shown()) diff --git a/tests/screens/application/old_files/test_broken_projects.py b/tests/screens/application/old_files/test_broken_projects.py new file mode 100644 index 000000000..165ff8f2b --- /dev/null +++ b/tests/screens/application/old_files/test_broken_projects.py @@ -0,0 +1,181 @@ +import operator +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.application.old_files.cases import STARTUP_CASES, StartupCase +from tests.screens.application.old_files.constants import ARCHIVED_PROJECT, VANISHED +from tests.screens.application.old_files.steps import ( + damaged_name, + future_version, + shown_notice, + stated_version, + stored_voice_names, + world_of, +) +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import Damage, archived_document, damaged_document +from tests.suite.screens.worlds.home import HomeFile, World + +SECOND_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Second.stp" +OLDER_PROJECT_VERSION: Final[str] = "0.9" +DOOR_SUFFIX_PROJECT: Final[str] = ".stp" +ARCHIVE_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.TRUNCATED, Damage.FOREIGN_BYTES) +VERSION_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.OLDER_VERSION, Damage.FUTURE_VERSION) + + +def damaged_project(damage: Damage) -> HomeFile: + """A damaged project file in the projects folder, one for each kind of ``damage``.""" + return damaged_document( + ObjectKind.PROJECT, + damage, + destination=PROJECTS_DIRECTORY / damaged_name(damage, ".stp"), + older_version=OLDER_PROJECT_VERSION, + future_version=future_version(SAMPLETONES_PROJECT_DATA_VERSION), + ) + + +def broken_project_world() -> World: + """A home with two sound projects, a damaged project for each damage, and a session.""" + return world_of( + archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT), + archived_document(ObjectKind.PROJECT, SECOND_PROJECT), + *(damaged_project(damage) for damage in Damage), + ) + + +def open_project(screen: Screen, path: Path) -> None: + """Chooses File > Open project with ``path`` and lets the open project be replaced.""" + project = screen.project + screen.answer_next_dialog(DialogKind.OPEN, path) + project.open() + screen.expect(project.replace_prompt.is_shown, bool, description="the question about replacing the project") + project.replace_prompt.confirm() + + +def refused_project(screen: Screen, path: Path) -> str: + """Reads the notice refusing ``path``, dismisses it, checks the project open before stays open, and + returns the words. + """ + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(path.name) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert ARCHIVED_PROJECT.stem in screen.title() + assert screen.sequencer.voices.names() == stored_voice_names() + return words + + +def open_the_second_project_the_same_way(screen: Screen) -> None: + """Opens the second sound project through the same menu and waits for its title.""" + open_project(screen, SECOND_PROJECT) + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + + +class TestRefusingABrokenProject: + """A project that cannot be read is refused with a message naming why, and the one open stays open. + + The archived project is open at start. Each damaged or vanished file is chosen from File > Open + project, and the notice names the file. A final open of a sound project succeeds, which shows the menu + still works. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds sound projects and a damaged project for each kind of damage.""" + return broken_project_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived project at start.""" + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_a_damaged_or_missing_archive_is_refused_naming_the_file(self, screen: Screen) -> None: + """The notice names the file for each damaged archive and for a file removed after it was chosen.""" + vanished = PROJECTS_DIRECTORY / f"{VANISHED}.stp" + + def refuse_each_damage(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in ARCHIVE_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + assert path.name in words + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + open_project(screen, vanished) + + words = refused_project(screen, vanished) + + assert vanished.name in words + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_the_second_project_the_same_way, + ).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a project at a version no step reaches is refused by its shape", + ) + def test_a_version_no_step_reaches_is_refused_for_its_version(self, screen: Screen) -> None: + """The notice states the file's version and the current version.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in VERSION_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + stated = stated_version(damage, SAMPLETONES_PROJECT_DATA_VERSION, OLDER_PROJECT_VERSION) + assert stated in words and SAMPLETONES_PROJECT_DATA_VERSION in words, words + + +class TestABrokenProjectGivenAtStart: + """A project handed over at start that cannot be read is set aside quietly, as the restore promises. + + The application starts on the Main tab with that project absent from the title. File > Open project + then opens a sound project, with no window left shown. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + """One row for each kind of damage, and one for a file that is gone.""" + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + """The home holds sound projects and a damaged project for each kind of damage.""" + return broken_project_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + """The application is handed the row's project at start.""" + return Startup(reconstruction=None, project=PROJECTS_DIRECTORY / case.name(DOOR_SUFFIX_PROJECT)) + + def test_it_starts_with_no_project_open(self, screen: Screen, case: StartupCase) -> None: + """The application starts with no project open and opens a sound one afterwards.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + assert case.name(DOOR_SUFFIX_PROJECT) not in screen.title() + screen.answer_next_dialog(DialogKind.OPEN, SECOND_PROJECT) + + screen.project.open() + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + assert screen.shown_windows() == () diff --git a/tests/screens/application/old_files/test_broken_reconstructions.py b/tests/screens/application/old_files/test_broken_reconstructions.py new file mode 100644 index 000000000..ed18f4cc8 --- /dev/null +++ b/tests/screens/application/old_files/test_broken_reconstructions.py @@ -0,0 +1,251 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, Optional, Type + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION +from sampletones_shared.exceptions.reconstruction import ( + IncompatibleReconstructionVersionError, + InvalidReconstructionValuesError, +) +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.old_files.cases import STARTUP_CASES, StartupCase +from tests.screens.application.old_files.constants import ARCHIVED_RECONSTRUCTION, BY_CONFIGURATION, VANISHED +from tests.screens.application.old_files.steps import ( + damaged_name, + future_version, + shown_notice, + stated_version, + world_of, +) +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import Damage, archived_document, damaged_document +from tests.suite.screens.seeds.recordings import stored_recording +from tests.suite.screens.worlds.home import HomeFile, World + +SECOND_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +OLDER_RECONSTRUCTION_VERSION: Final[str] = "2.0" +INVALID_VALUES: Final[str] = "reconstructions.browser.message.invalid_values" +INCOMPATIBLE_VERSION: Final[str] = "reconstructions.browser.template.incompatible_version_template" +RECONSTRUCTION_NOT_FOUND: Final[str] = "reconstructions.browser.message.file_not_found" +DOOR_SUFFIX_RECONSTRUCTION: Final[str] = ".stn" + + +def damaged_reconstruction(damage: Damage) -> HomeFile: + """A damaged reconstruction file in the reconstructions folder for ``damage``.""" + return damaged_document( + ObjectKind.RECONSTRUCTION, + damage, + destination=RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, ".stn"), + older_version=OLDER_RECONSTRUCTION_VERSION, + future_version=future_version(SAMPLETONES_RECONSTRUCTION_DATA_VERSION), + ) + + +def reconstruction_refusal(screen: Screen, damage: Damage) -> str: + """The words the application uses to refuse a reconstruction with ``damage``, as the language file has + them. + """ + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + stated = stated_version(damage, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, OLDER_RECONSTRUCTION_VERSION) + return screen.words(INCOMPATIBLE_VERSION).format(stated, SAMPLETONES_RECONSTRUCTION_DATA_VERSION) + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return screen.words(INVALID_VALUES) + + +def reconstruction_failure(damage: Optional[Damage]) -> Type[BaseException]: + """The failure type the application records for a reconstruction with ``damage``, or for a file that is + gone. + """ + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + return IncompatibleReconstructionVersionError + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return InvalidReconstructionValuesError + case None: + return FileNotFoundError + + +def broken_reconstruction_world() -> World: + """A home with sound reconstructions, a recording, and a damaged reconstruction for each damage.""" + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, SECOND_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn"), + stored_recording(), + *(damaged_reconstruction(damage) for damage in Damage), + ) + + +def refused_reconstruction( + screen: Screen, + damage: Optional[Damage], + expected: str, +) -> None: + """Reads the notice refusing a reconstruction, dismisses it, and checks the reconstruction open before + stays open. + """ + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(reconstruction_failure(damage).__name__) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert words.startswith(expected), words + assert screen.reconstructions.shows_open(ARCHIVED_RECONSTRUCTION) + + +class TestRefusingABrokenReconstruction: + """A reconstruction that cannot be read is refused with a readable message, and the one open stays open. + + Each door that opens a reconstruction is walked with every kind of damage, and then with a file removed + after it was listed or chosen. The last gesture opens a sound reconstruction the same way, which shows + the door still opens what it should. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds sound reconstructions and a damaged one for each kind of damage.""" + return broken_reconstruction_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived reconstruction at start.""" + return Startup(reconstruction=ARCHIVED_RECONSTRUCTION, project=None) + + def test_through_the_browser(self, screen: Screen) -> None: + """The door is a double click on a row of the reconstructions browser.""" + browser = screen.reconstructions.browser + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def open_the_listing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + browser.open_by_click(heading) + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + row = screen.expect_item(partial(browser.file_row, path), description="its row") + + browser.double_click(row) + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_listed(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(vanished), description="its row") + vanished.unlink() + + browser.double_click(row) + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(SECOND_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect( + lambda: screen.reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + open_the_listing, + refuse_each_damage, + refuse_a_file_removed_after_it_was_listed, + open_a_sound_one_the_same_way, + ).run() + + def test_through_reconstruction_open(self, screen: Screen) -> None: + """The door is the Reconstruction > Open menu entry.""" + reconstructions = screen.reconstructions + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + screen.answer_next_dialog(DialogKind.OPEN, path) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + vanished.unlink() + screen.answer_next_dialog(DialogKind.OPEN, vanished) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_a_sound_one_the_same_way, + ).run() + + +class TestABrokenReconstructionGivenAtStart: + """A reconstruction handed over at start that cannot be read is set aside quietly, as the restore + promises. + + The application starts quietly with nothing open; Reconstruction > Open then opens a sound + reconstruction, which shows the start left the application as usable as ever. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + """One row for each kind of damage, and one for a file that is gone.""" + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + """The home holds sound reconstructions and a damaged one for each kind of damage.""" + return broken_reconstruction_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + """The application is handed the row's reconstruction at start.""" + return Startup(reconstruction=RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION), project=None) + + def test_it_starts_with_nothing_open(self, screen: Screen, case: StartupCase) -> None: + """No window shows, and a sound reconstruction opens afterwards.""" + if case.damage is None: + (RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION)).unlink(missing_ok=True) + reconstructions = screen.reconstructions + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + assert screen.shown_windows() == () diff --git a/tests/screens/application/old_files/test_last_release_documents.py b/tests/screens/application/old_files/test_last_release_documents.py new file mode 100644 index 000000000..bcf40c61d --- /dev/null +++ b/tests/screens/application/old_files/test_last_release_documents.py @@ -0,0 +1,130 @@ +from typing import Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.playback import PlaybackConfig +from sampletones_core.compatibility.kind import ObjectKind +from tests.screens.application.old_files.constants import ARCHIVED_PROJECT, ARCHIVED_RECONSTRUCTION, BY_CONFIGURATION +from tests.screens.application.old_files.steps import stored_voice_names, world_of +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import archived_document +from tests.suite.screens.seeds.recordings import stored_recording +from tests.suite.screens.worlds.home import World, screen_filling_state + + +class TestTheLastReleasesReconstruction: + """A reconstruction the last release wrote opens in this build, its recording found where it names it.""" + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's reconstruction and its recording.""" + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + stored_recording(), + ) + + def test_it_opens_from_the_browser(self, screen: Screen) -> None: + """A double click on the browser's row opens the reconstruction with no notice shown. + + The scenario opens the by-configuration listing on the Reconstructions tab and double-clicks the + row. + """ + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: reconstructions.browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + reconstructions.browser.open_by_click(heading) + row = screen.expect_item( + lambda: reconstructions.browser.file_row(ARCHIVED_RECONSTRUCTION), description="its row" + ) + + reconstructions.browser.double_click(row) + + screen.expect( + lambda: reconstructions.shows_open(ARCHIVED_RECONSTRUCTION), + bool, + description="the archived reconstruction open", + ) + assert not screen.file_not_found_notice.is_shown() + assert not screen.error_notice.is_shown() + + +class TestTheLastReleasesProject: + """A project the last release wrote opens in this build, lists its voices and plays its song. + + The project opens at start with song looping on. The Sequencer lists the stored voices, Play turns the + entry to Pause and enables Stop, and Stop returns the entry to Play. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's project and a session with song looping on.""" + looping = ApplicationConfig(playback=PlaybackConfig(loop_song=True)) + return World( + state=screen_filling_state(), + application_config=looping, + config=None, + files=(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT),), + ) + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived project at start.""" + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_it_opens_at_start_lists_its_voices_and_plays_its_song(self, screen: Screen) -> None: + """The voices match the stored ones, and playback starts and stops.""" + sequencer = screen.sequencer + playing_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PAUSE)) + stopped_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PLAY)) + + def lists_its_voices(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + def plays(screen: Screen) -> None: + assert sequencer.playback.play_entry() == stopped_entry + + sequencer.playback.play() + + screen.expect(sequencer.playback.play_entry, playing_entry.__eq__, description="Play reading Pause") + assert sequencer.playback.can_stop() + + def stops(screen: Screen) -> None: + sequencer.playback.stop() + + screen.expect(sequencer.playback.play_entry, stopped_entry.__eq__, description="Pause reading Play") + assert not sequencer.playback.can_stop() + + screen.scenario(lists_its_voices, plays, stops).run() + + +class TestOpeningTheLastReleasesProjectFromTheMenu: + """File > Open project opens a project the last release wrote.""" + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's project.""" + return world_of(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT)) + + def test_it_lists_the_stored_voices(self, screen: Screen) -> None: + """The Sequencer lists the voices the file stores.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.OPEN, ARCHIVED_PROJECT) + + screen.project.open() + + screen.expect(screen.sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + +def _menu_key(element: MenuElements) -> Tuple[Page, Panel, TextType, MenuElements]: + """The language key of a menu label.""" + return (Page.GLOBAL, Panel.MENU, TextType.LABEL, element) diff --git a/tests/screens/application/old_files/test_last_release_library.py b/tests/screens/application/old_files/test_last_release_library.py new file mode 100644 index 000000000..d1ec2b48e --- /dev/null +++ b/tests/screens/application/old_files/test_last_release_library.py @@ -0,0 +1,95 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.screens.application.old_files.steps import world_of +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document, stored_version +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import archived_document +from tests.suite.screens.vocabulary.libraries import LIBRARY_LOADED +from tests.suite.screens.worlds.home import World + +CONFIG_FIELD: Final[str] = "config" +OUTDATED_ROW: Final[str] = "instructions.library.template.library_node_outdated_template" +REBUILD_TIMEOUT_SECONDS: Final[float] = 300.0 + + +def archived_library_path() -> Path: + """Where the archived library lies in the library folder, named as its settings name a library file.""" + document = stored_document(archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY])) + config = Config(library=InstructionsLibraryConfig.model_validate(document[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +class TestTheLastReleasesLibrary: + """A library the last release built reads as out of date, and only a rebuild the user asks for replaces + it. + + The Instructions tab marks the library as outdated. Opening it asks about a rebuild; Cancel keeps the + file as it was, and the next opening asks again. Confirming rebuilds the library in place: the row + loses its mark, the file holds the current data version, and the folder holds that file alone. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's library file.""" + return world_of(archived_document(ObjectKind.LIBRARY, archived_library_path())) + + def test_cancel_leaves_it_and_rebuild_rebuilds_it_in_place(self, screen: Screen) -> None: + """Cancel keeps the file's bytes; the rebuild replaces the file in place.""" + library = screen.instructions.library + path = archived_library_path() + archived_bytes = path.read_bytes() + outdated_mark = screen.words(OUTDATED_ROW).format("") + + def library_row() -> Item: + return screen.expect_item(lambda: library.row(path.name), description="the library's row") + + def reads_out_of_date(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + assert library.tree.label(library_row()).startswith(outdated_mark) + + def cancel_leaves_it(screen: Screen) -> None: + prompt = library.rebuild_prompt + library.tree.open_by_click(library_row()) + screen.expect(prompt.is_shown, bool, description="the rebuild question") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + row = library_row() + if library.tree.is_open(row): + library.tree.open_by_click(row) + screen.expect(lambda: library.tree.is_open(row), operator.not_, description="the row closed") + library.tree.open_by_click(row) + screen.expect(prompt.is_shown, bool, description="the rebuild question asked at the next opening") + assert path.read_bytes() == archived_bytes + + def rebuild_replaces_it_in_place(screen: Screen) -> None: + name = library.tree.label(library_row()).removeprefix(outdated_mark) + + library.rebuild_prompt.confirm() + + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the rebuilt library loaded", + timeout=REBUILD_TIMEOUT_SECONDS, + ) + assert library.tree.label(library_row()) == name + assert stored_version(ObjectKind.LIBRARY, path) == SAMPLETONES_LIBRARY_DATA_VERSION + assert sorted(LIBRARY_DIRECTORY.glob(f"*{path.suffix}")) == [path] + + screen.scenario(reads_out_of_date, cancel_leaves_it, rebuild_replaces_it_in_place).run() diff --git a/tests/screens/application/old_files/test_session_files_from_elsewhere.py b/tests/screens/application/old_files/test_session_files_from_elsewhere.py new file mode 100644 index 000000000..d2de6803c --- /dev/null +++ b/tests/screens/application/old_files/test_session_files_from_elsewhere.py @@ -0,0 +1,84 @@ +import json +from pathlib import Path +from typing import Final + +import pytest +import yaml + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.state.current import Current +from sampletones_application.paths import APPLICATION_STATE_PATH +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import WrittenBytes +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.home import World, screen_filling_state + +RECORDING_SECONDS: Final[float] = 0.5 +RECORDING_FREQUENCY: Final[float] = 220.0 +UNKNOWN_CARD: Final[str] = "main.retired.panel" +GONE_FOLDER: Final[str] = "Gone" +LIBRARIES_FOLDER: Final[str] = "Libraries" +VIEWPORT_FIELD: Final[str] = "viewport" +WIDTH_FIELD: Final[str] = "width" +WRONG_KIND_OF_WIDTH: Final[str] = "wide" +GENERAL_FIELD: Final[str] = "general" +MISSING_SETTING: Final[str] = "max_workers" + + +def state_from_elsewhere() -> bytes: + """A session another build left: it names a card and a folder this home lacks, and a width in words.""" + state = screen_filling_state().model_copy( + update={ + "advanced_settings": True, + "current": Current(tab=Tab.SEQUENCER), + "collapsed_cards": {UNKNOWN_CARD: True}, + "expanded_directories": [Path.cwd() / GONE_FOLDER], + } + ) + document = state.model_dump(mode="json") + document[VIEWPORT_FIELD][WIDTH_FIELD] = WRONG_KIND_OF_WIDTH + return yaml.safe_dump(document).encode() + + +def config_from_elsewhere() -> bytes: + """Reconstruction settings another build left: they name a library folder and lack a setting.""" + config = Config() + general = config.general.model_copy(update={"library_directory": str(Path.cwd() / LIBRARIES_FOLDER)}) + document = config.model_copy(update={"general": general}).model_dump(mode="json") + del document[GENERAL_FIELD][MISSING_SETTING] + return json.dumps(document).encode() + + +class TestSessionFilesFromElsewhere: + """A session naming what this home lacks, with a setting of the wrong kind, beside settings missing one. + + The application starts quietly and applies every setting the files give in a form this build reads: the + Sequencer tab is in front, Advanced settings is shown, and the library folder is the one the settings + name. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the foreign session, the foreign settings and a recording in the named library + folder. + """ + return World( + state=None, + application_config=None, + config=None, + files=( + WrittenBytes(APPLICATION_STATE_PATH, state_from_elsewhere()), + WrittenBytes(CONFIG_PATH, config_from_elsewhere()), + Recording(Path.cwd() / LIBRARIES_FOLDER / "kick.wav", RECORDING_SECONDS, RECORDING_FREQUENCY), + ), + ) + + def test_it_starts_without_a_word_and_applies_what_it_reads(self, screen: Screen) -> None: + """No window shows, and the readable settings apply.""" + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + + assert screen.shown_windows() == () + assert screen.main.advanced.is_shown() + assert screen.main.library_directory() == str(Path.cwd() / LIBRARIES_FOLDER) diff --git a/tests/screens/application/restart/__init__.py b/tests/screens/application/restart/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/restart/constants.py b/tests/screens/application/restart/constants.py new file mode 100644 index 000000000..4afe278bb --- /dev/null +++ b/tests/screens/application/restart/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +RECORDINGS_FOLDER: Final[str] = "Recordings" +RECORDING_NAME: Final[str] = "kick.wav" +RECORDING_SECONDS: Final[float] = 0.25 +RECORDING_FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/application/restart/steps.py b/tests/screens/application/restart/steps.py new file mode 100644 index 000000000..2d0cda654 --- /dev/null +++ b/tests/screens/application/restart/steps.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.restart.constants import RECORDING_FREQUENCY, RECORDING_NAME, RECORDING_SECONDS +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.home import HomeFile, World, screen_filling_state + + +def home_folder(name: str) -> Path: + """A folder of the home the application was started in.""" + return Path.cwd() / name + + +def recording_in(folder: str) -> HomeFile: + """A short recording placed in ``folder`` of the home.""" + return Recording( + destination=home_folder(folder) / RECORDING_NAME, + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + + +def leave(screen: Screen) -> None: + """Leaves through Exit, which writes the session, and waits for the application to stop.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +def world_with(state: ApplicationState, *files: HomeFile) -> World: + """A home with ``state`` as its session and ``files``.""" + return World( + state=state, + application_config=None, + config=None, + files=files, + ) + + +def state_with_advanced(shown: bool) -> ApplicationState: + """A screen-filling session with Advanced settings shown or hidden as ``shown`` says.""" + return screen_filling_state().model_copy(update={"advanced_settings": shown}) diff --git a/tests/screens/application/restart/test_cards.py b/tests/screens/application/restart/test_cards.py new file mode 100644 index 000000000..6f8a51225 --- /dev/null +++ b/tests/screens/application/restart/test_cards.py @@ -0,0 +1,69 @@ +import pytest + +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.restart.steps import leave, state_with_advanced, world_with +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_state + + +class TestTheAdvancedCardAcrossARestart: + """Whether Advanced settings stands is written as the application leaves, and read as it starts.""" + + @pytest.fixture(params=[False, True], ids=["hidden", "shown"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + """The state the session holds: hidden or shown.""" + shown: bool = request.param + return shown + + @pytest.fixture + def world(self, seeded: bool) -> World: + """The home holds a session with Advanced settings as seeded.""" + return world_with(state_with_advanced(seeded)) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + """The card is shown as the session says.""" + screen.expect(screen.main.advanced.is_shown, seeded.__eq__, description="the card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + """After the toggle shortcut and Exit, the session holds the opposite of the seeded state.""" + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, seeded.__ne__, description="the card toggled") + + leave(screen) + + assert written_state().advanced_settings is not seeded + + +class TestACollapsedCardAcrossARestart: + """A card folded into its bar is written as the application leaves, and stands folded at next start.""" + + @pytest.fixture(params=[False, True], ids=["open", "collapsed"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + """The state the session holds for the Converter card: open or collapsed.""" + collapsed: bool = request.param + return collapsed + + @pytest.fixture + def world(self, seeded: bool) -> World: + """The home holds a session with the Converter card as seeded.""" + state = screen_filling_state().model_copy(update={"collapsed_cards": {TAG_MAIN_CONVERTER_PANEL: seeded}}) + return world_with(state) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + """The Converter card is collapsed as the session says.""" + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + """After the toggle and Exit, the session holds the opposite of the seeded state.""" + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + card.toggle() + + screen.expect(card.is_collapsed, seeded.__ne__, description="the Converter card toggled") + leave(screen) + assert written_state().collapsed_cards.get(TAG_MAIN_CONVERTER_PANEL) is not seeded diff --git a/tests/screens/application/restart/test_converter_and_library.py b/tests/screens/application/restart/test_converter_and_library.py new file mode 100644 index 000000000..465e1370f --- /dev/null +++ b/tests/screens/application/restart/test_converter_and_library.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.converter import ConverterConfig +from sampletones_application.constants.output import OutputKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.screens.application.restart.constants import RECORDINGS_FOLDER +from tests.screens.application.restart.steps import home_folder, leave, recording_in, state_with_advanced +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_config + +LIBRARIES_FOLDER: Final[str] = "Libraries" +CHOSEN_CHANNELS_AT_ONCE: Final[int] = 2 +SEEDED_CHANNELS_AT_ONCE: Final[int] = 3 + + +def converter_config(output: OutputKind, channels_at_once: int) -> ConverterConfig: + """A converter setting for ``output`` that allows ``channels_at_once`` channels for each step.""" + settings = StemSettings.covering(list(DEFAULT_CHANNELS)).model_copy(update={"channel_cap": channels_at_once}) + return ConverterConfig(output=output, settings=settings) + + +class TestTheConverterSettingsAcrossARestart: + """The converter's output and its channels at once are written as the application leaves, and stand at the + next start. + + The home seeds the mixed output and three channels at once. The scenario checks both, chooses per- + recording output and two channels, and expects Exit to write the chosen values. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a session and settings with the mixed output and three channels at once.""" + return World( + state=screen_filling_state(), + application_config=ApplicationConfig( + converter=converter_config(OutputKind.MIXED, SEEDED_CHANNELS_AT_ONCE), + ), + config=None, + files=(), + ) + + def test_a_home_holding_them_shows_them_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded values show at start and the chosen values are written at exit.""" + main = screen.main + + def shows_the_seeded_ones(screen: Screen) -> None: + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the output the settings name") + assert main.lit_channels_at_once() == SEEDED_CHANNELS_AT_ONCE + + def changes_them(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + main.choose_channels_at_once(CHOSEN_CHANNELS_AT_ONCE) + + screen.expect(main.lit_channels_at_once, CHOSEN_CHANNELS_AT_ONCE.__eq__, description="the step lit") + assert main.output() is OutputKind.PER_RECORDING + + def leaving_writes_them(screen: Screen) -> None: + leave(screen) + + converter = written_application_config().converter + assert converter.output is OutputKind.PER_RECORDING + assert converter.settings.channel_cap == CHOSEN_CHANNELS_AT_ONCE + + screen.scenario(shows_the_seeded_ones, changes_them, leaving_writes_them).run() + + +class TestTheLibraryFolderAcrossARestart: + """The library folder Advanced settings points at is written as the application leaves, and named at the + next start. + + The home seeds one folder as the library folder. The scenario checks it is named, chooses another + folder through the directory dialog, and expects Exit to write the chosen folder. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds Advanced settings shown, settings naming the Libraries folder, and recordings in two + folders. + """ + config = Config() + general = config.general.model_copy(update={"library_directory": str(home_folder(LIBRARIES_FOLDER))}) + return World( + state=state_with_advanced(True), + application_config=None, + config=config.model_copy(update={"general": general}), + files=(recording_in(LIBRARIES_FOLDER), recording_in(RECORDINGS_FOLDER)), + ) + + def test_a_home_holding_it_names_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded folder shows at start and the chosen folder is written at exit.""" + main = screen.main + chosen = home_folder(RECORDINGS_FOLDER) + + def names_the_seeded_one(screen: Screen) -> None: + screen.expect( + main.library_directory, + str(home_folder(LIBRARIES_FOLDER)).__eq__, + description="the library folder the settings name", + ) + + def points_at_another(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.DIRECTORY, chosen) + + main.choose_library_directory() + + screen.expect(main.library_directory, str(chosen).__eq__, description="the chosen folder named") + + def leaving_writes_it(screen: Screen) -> None: + leave(screen) + + assert Path(written_config().general.library_directory) == chosen + + screen.scenario(names_the_seeded_one, points_at_another, leaving_writes_it).run() diff --git a/tests/screens/application/restart/test_deleted_files.py b/tests/screens/application/restart/test_deleted_files.py new file mode 100644 index 000000000..10b25350b --- /dev/null +++ b/tests/screens/application/restart/test_deleted_files.py @@ -0,0 +1,106 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.favorites import Favorites +from sampletones_application.config.session.state.current import Current +from sampletones_application.config.session.state.paths import LastPaths +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.restart.steps import home_folder, leave +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_state + +DELETED_FOLDER: Final[str] = "Deleted" +DELETED_SUBFOLDER: Final[str] = "Deeper" +DELETED_RECONSTRUCTION: Final[str] = "Deleted.stn" +DELETED_FAVORITE: Final[str] = "deleted.wav" + + +def deleted_folder() -> Path: + """The folder inside the home that the session names and the home lacks.""" + return home_folder(DELETED_FOLDER) / DELETED_SUBFOLDER + + +def deleted_reconstruction() -> Path: + """The reconstruction the session names and the home lacks.""" + return RECONSTRUCTIONS_DIRECTORY / DELETED_RECONSTRUCTION + + +def deleted_favorite() -> Path: + """The starred file the settings name and the home lacks.""" + return home_folder(DELETED_FAVORITE) + + +class TestASessionNamingDeletedFiles: + """A session naming a folder, a reconstruction and a starred file deleted since the last run. + + The application starts quietly with nothing restored, a dialog opens in the nearest folder + still standing, and leaving writes none of the three back. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a session and settings naming a folder, a reconstruction and a starred file that are + gone. + """ + state = screen_filling_state().model_copy( + update={ + "current": Current(reconstruction=deleted_reconstruction()), + "last_paths": LastPaths(reconstruction=deleted_folder()), + } + ) + return World( + state=state, + application_config=ApplicationConfig(favorites=Favorites(paths={deleted_favorite()})), + config=None, + files=(), + ) + + def test_it_restores_nothing_and_asks_in_the_nearest_standing_folder(self, screen: Screen) -> None: + """The Open dialog starts in the current folder, and no file is open.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, None) + + screen.reconstructions.open_from_menu() + + request = screen.expect( + screen.dialog_requests, + lambda requests: len(requests) == 1, + description="the open dialog", + )[0] + assert request.initial_directory == Path.cwd() + assert screen.shown_windows() == () + assert screen.reconstructions.open_file() == "" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: leaving keeps the last dialog folder a run found deleted", + ) + def test_the_session_left_names_neither_the_folder_nor_the_reconstruction(self, screen: Screen) -> None: + """The session written at exit holds no reconstruction and a different dialog folder.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + state = written_state() + assert state.current.reconstruction is None + assert state.last_paths.reconstruction != deleted_folder() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: leaving keeps a starred file a run found deleted", + ) + def test_the_settings_left_name_no_deleted_starred_file(self, screen: Screen) -> None: + """The settings written at exit leave the deleted file out of the starred paths.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + assert deleted_favorite() not in written_application_config().favorites.paths diff --git a/tests/screens/application/restart/test_explorer_and_tab.py b/tests/screens/application/restart/test_explorer_and_tab.py new file mode 100644 index 000000000..2d09688f9 --- /dev/null +++ b/tests/screens/application/restart/test_explorer_and_tab.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.state.current import Current +from tests.screens.application.restart.constants import RECORDINGS_FOLDER +from tests.screens.application.restart.steps import home_folder, leave, recording_in, world_with +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_state + +UNTOUCHED_FOLDER: Final[str] = "Untouched" + + +def explorer_world(opened: List[Path]) -> World: + """A home with ``opened`` folders expanded in the session and recordings in two folders.""" + state = screen_filling_state().model_copy(update={"expanded_directories": opened}) + return world_with(state, recording_in(RECORDINGS_FOLDER), recording_in(UNTOUCHED_FOLDER)) + + +def expect_row(screen: Screen, folder: Path) -> Item: + """Waits for the explorer's row of ``folder`` and returns it.""" + return screen.expect_item( + lambda: screen.explorer.file_row(folder), + description=f"the row of {folder.name}", + ) + + +class TestLeavingWritesAFolderOpenedInTheExplorer: + """A folder opened in the explorer is written as the application leaves; a sibling left closed is left + out. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds two folders with a recording each and a session with none opened.""" + return explorer_world([]) + + def test_the_session_names_the_opened_folder_alone(self, screen: Screen) -> None: + """The session at exit names the opened folder and leaves the sibling out.""" + explorer = screen.explorer + opened = home_folder(RECORDINGS_FOLDER) + row = expect_row(screen, opened) + assert not explorer.is_open(row) + + explorer.double_click(row) + + screen.expect(lambda: explorer.is_open(row), bool, description="the folder open") + leave(screen) + written = written_state().expanded_directories + assert opened in written + assert home_folder(UNTOUCHED_FOLDER) not in written + + +class TestAHomeHoldingAnOpenedFolderShowsIt: + """A folder the session names as opened stands open at the start; a sibling left out stands closed.""" + + @pytest.fixture + def world(self) -> World: + """The home holds two folders with a recording each and a session naming one as opened.""" + return explorer_world([home_folder(RECORDINGS_FOLDER)]) + + def test_the_named_folder_stands_open_alone(self, screen: Screen) -> None: + """The named folder is open and its sibling is closed.""" + explorer = screen.explorer + opened = expect_row(screen, home_folder(RECORDINGS_FOLDER)) + closed = expect_row(screen, home_folder(UNTOUCHED_FOLDER)) + + screen.expect(lambda: explorer.is_open(opened), bool, description="the named folder open") + assert not explorer.is_open(closed) + + +class TestTheTabInFrontAcrossARestart: + """The tab in front is written as the application leaves, and brought forward at the next start.""" + + @pytest.fixture + def world(self) -> World: + """The home holds a session with the Instructions tab in front.""" + state = screen_filling_state().model_copy(update={"current": Current(tab=Tab.INSTRUCTIONS)}) + return world_with(state) + + def test_a_home_holding_it_shows_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded tab is in front at start, and the tab brought forward is written at exit.""" + screen.expect(screen.tabs.front, Tab.INSTRUCTIONS.__eq__, description="the Instructions tab in front") + + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + leave(screen) + assert written_state().current.tab is Tab.SEQUENCER diff --git a/tests/screens/application/startup/__init__.py b/tests/screens/application/startup/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/startup/test_fresh_home.py b/tests/screens/application/startup/test_fresh_home.py new file mode 100644 index 000000000..e04cf9d14 --- /dev/null +++ b/tests/screens/application/startup/test_fresh_home.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World +from tests.suite.screens.written import written_application_config, written_config, written_state + + +class TestAFreshHome: + """The application starts in a home no run has used, and leaves behind what the next start reads. + + The application opens on the folder it was started in with the Main tab in front and no window shown, + and lists the library and reconstructions folders. Exit then writes the Main tab as the session's tab, + the library folder in the settings, and the application's version. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds no session, no settings and no files.""" + return World( + state=None, + application_config=None, + config=None, + files=(), + ) + + def test_it_opens_on_the_folder_it_was_started_in_and_leaves_its_settings(self, screen: Screen) -> None: + """The first start opens on its folder, and Exit writes the session and both settings files.""" + explorer = screen.explorer + + def opens_on_its_folder(screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + home = screen.expect_item( + lambda: explorer.file_row(Path.cwd()), + description="the folder the application was started in", + ) + + assert explorer.is_open(home) + assert screen.shown_windows() == () + + def lists_the_folders_it_keeps_its_documents_in(screen: Screen) -> None: + for folder in (LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY): + assert explorer.file_row(folder) is not None, folder + + def leaves_its_settings_behind(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_state().current.tab is Tab.MAIN + assert Path(written_config().general.library_directory) == LIBRARY_DIRECTORY + assert written_application_config().metadata.version is not None + + screen.scenario( + opens_on_its_folder, + lists_the_folders_it_keeps_its_documents_in, + leaves_its_settings_behind, + ).run() diff --git a/tests/screens/application/startup/test_tabs.py b/tests/screens/application/startup/test_tabs.py new file mode 100644 index 000000000..a2465d9ae --- /dev/null +++ b/tests/screens/application/startup/test_tabs.py @@ -0,0 +1,19 @@ +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.screen import Screen + + +class TestTabs: + """The tab bar across the window brings a tab forward at a click on its header.""" + + def test_each_header_brings_its_tab_forward(self, screen: Screen) -> None: + """Each header brought forward by a click puts its tab in front.""" + for tab in (Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS, Tab.MAIN): + screen.tabs.bring_to_front(tab) + + front = screen.expect( + screen.tabs.front, + tab.__eq__, + description=f"the {tab} tab in front", + ) + + assert front == tab diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py new file mode 100644 index 000000000..cf7791659 --- /dev/null +++ b/tests/screens/conftest.py @@ -0,0 +1,43 @@ +from tests.suite.screens.plugin.fixtures import ( + output_device, + screen, + screen_application, + screen_boundaries, + screen_bridge, + screen_render_thread, + startup, + world, +) +from tests.suite.screens.plugin.hold_fixtures import ( + conversion_hold, + export_hold, + regeneration_hold, + scan_hold, + screen_holds, +) +from tests.suite.screens.plugin.hooks import ( + pytest_configure, + pytest_pyfunc_call, + pytest_runtest_protocol, + pytest_unconfigure, +) + +__all__ = [ + "conversion_hold", + "export_hold", + "output_device", + "pytest_configure", + "pytest_pyfunc_call", + "pytest_runtest_protocol", + "pytest_unconfigure", + "regeneration_hold", + "scan_hold", + "screen", + "screen_application", + "screen_boundaries", + "screen_bridge", + "screen_holds", + "screen_render_thread", + "startup", + "world", +] diff --git a/tests/screens/exports/__init__.py b/tests/screens/exports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/conftest.py b/tests/screens/exports/conftest.py new file mode 100644 index 000000000..13601a17d --- /dev/null +++ b/tests/screens/exports/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import exporting_world + + +@pytest.fixture +def world() -> World: + """The home every export scenario starts in: the arranged project, and projects meeting an export's limits.""" + return exporting_world() diff --git a/tests/screens/exports/instruments/__init__.py b/tests/screens/exports/instruments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/conftest.py b/tests/screens/exports/instruments/conftest.py new file mode 100644 index 000000000..ecb5bcb2f --- /dev/null +++ b/tests/screens/exports/instruments/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """The home opens with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/exports/instruments/doors/__init__.py b/tests/screens/exports/instruments/doors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/doors/test_doors.py b/tests/screens/exports/instruments/doors/test_doors.py new file mode 100644 index 000000000..766f411a4 --- /dev/null +++ b/tests/screens/exports/instruments/doors/test_doors.py @@ -0,0 +1,237 @@ +import operator +from pathlib import Path +from typing import Final, List, Optional, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.instrument import read_fti +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_NSF +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items.texts import EntryReading +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice, open_voice_menu +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT, NSF_TYPE +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +EXPORT_TITLE: Final[str] = "reconstructions.instruments.title.export_instrument_dialog" +EXPORTED: Final[str] = "reconstructions.instruments.message.export_instrument_success" +FAMITRACKER_TYPE: Final[str] = "global.dialog.filter.famitracker_instrument" +BITPHASE_PRESET_TYPE: Final[str] = "global.dialog.filter.bitphase_preset" +FTI_MAGIC: Final[bytes] = b"FTI" +JSON_OPENING: Final[bytes] = b"{" +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +EMPTY: Final[str] = "" +RENAMED: Final[str] = "Renamed" +EXPORTS: Final[str] = "exports" + + +def folder(*parts: str) -> Path: + """Returns a folder of the home, created when missing, that a save dialog can answer with as a native + dialog would. + """ + path = Path.cwd().joinpath(EXPORTS, *parts) + path.mkdir(parents=True, exist_ok=True) + return path + + +def export_entry(screen: Screen) -> Optional[EntryReading]: + """The plain Export instrument... entry of the open menu, if it offers one.""" + words = screen.words(EXPORT_INSTRUMENT) + return next((entry for entry in screen.context_menu.entries() if entry.label == words), None) + + +def export_from_the_menu(screen: Screen, voice: str) -> None: + """Chooses Export instrument... on the menu of ``voice``, a voice holding one instrument.""" + open_voice_menu(screen, voice) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +def written(screen: Screen, path: Path) -> None: + """Waits for the notice naming the instrument file ``path`` written, and dismisses it.""" + notice = screen.exports.file_notice + screen.expect(notice.is_shown, bool, description=f"the notice of {path.name}") + words = notice.words() + assert screen.words(EXPORTED) in words + assert str(path) in words + assert path.is_file() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project only exported from, which goes through at once: an export leaves the project as it + was. + """ + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestTheExportEntryFollowsWhatAVoiceHolds: + """Export instrument... is one entry on a voice holding one instrument and a submenu of channels on a + sample holding several; a voice holding none greys it, and greys the Reconstructions tab's button + with it. + + The menus of the hand-written voice, the three-channel sample and the one-channel sample are read in + turn. Then the envelopes of the hand-written voice are emptied, and the button and the menu entry + end greyed. + """ + + def test_one_entry_a_submenu_and_a_greyed_entry(self, screen: Screen) -> None: + """Each voice shows the entry that matches the instruments it holds.""" + menu = screen.context_menu + instruments = screen.reconstructions.instruments + + def a_hand_written_voice_offers_one_entry(screen: Screen) -> None: + open_voice_menu(screen, PAD) + + entry = export_entry(screen) + assert entry is not None + assert entry.enabled + assert screen.words(EXPORT_INSTRUMENT) not in menu.submenus() + menu.dismiss() + + def a_sample_of_three_channels_names_each(screen: Screen) -> None: + open_voice_menu(screen, LINE) + + assert export_entry(screen) is None + assert menu.submenu(screen.words(EXPORT_INSTRUMENT)) == [ + (screen.channel_words(channel), True) for channel in LINE_CHANNELS + ] + menu.dismiss() + + def a_sample_of_one_channel_offers_one_entry(screen: Screen) -> None: + open_voice_menu(screen, BASS_VOICE) + + entry = export_entry(screen) + assert entry is not None + assert entry.enabled + assert screen.words(EXPORT_INSTRUMENT) not in menu.submenus() + menu.dismiss() + + def a_voice_left_without_envelopes_greys_both(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the hand-written voice open") + assert instruments.can_export(INSTRUMENT_CHANNEL) + + for feature in (FeatureKey.VOLUME, FeatureKey.ARPEGGIO): + instruments.type_envelope(INSTRUMENT_CHANNEL, feature, EMPTY) + + screen.expect( + lambda: instruments.can_export(INSTRUMENT_CHANNEL), + operator.not_, + description="the button greyed", + ) + open_voice_menu(screen, PAD) + entry = export_entry(screen) + assert entry is not None + assert not entry.enabled + menu.dismiss() + + screen.scenario( + a_hand_written_voice_offers_one_entry, + a_sample_of_three_channels_names_each, + a_sample_of_one_channel_offers_one_entry, + a_voice_left_without_envelopes_greys_both, + leave_letting_the_project_go, + ).run() + + +class TestEveryDoorWritesTheSameBytes: + """The Reconstructions tab's button and the voice's menu write the same file for the same voice. + + The hand-written voice is exported through the button, then through the menu, into two folders. The + two files hold the same bytes. + """ + + def test_the_button_and_the_menu_agree(self, screen: Screen) -> None: + """The two files written for the same voice hold the same bytes.""" + instruments = screen.reconstructions.instruments + through_the_button = folder("button") / f"{PAD}{EXT_FILE_INSTRUMENT}" + through_the_menu = folder("menu") / f"{PAD}{EXT_FILE_INSTRUMENT}" + + def export_through_the_button(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the hand-written voice open") + screen.answer_next_dialog(DialogKind.SAVE, through_the_button) + + instruments.export(INSTRUMENT_CHANNEL) + + written(screen, through_the_button) + + def export_through_the_menu(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, through_the_menu) + + export_from_the_menu(screen, PAD) + + written(screen, through_the_menu) + assert through_the_menu.read_bytes() == through_the_button.read_bytes() + + screen.scenario(export_through_the_button, export_through_the_menu, leaving_asks_nothing).run() + + +class TestOneDialogOffersEveryType: + """One save dialog offers the three instrument types: the type picked decides the format, and the name + the instrument inside. + + The voice is exported from its menu with each type picked in turn, then with a new file name. Each + file starts as its format does, and the instrument inside the FamiTracker file takes the name of the + file. + """ + + def test_the_picked_type_decides_the_format(self, screen: Screen) -> None: + """Each type writes its own format, and the dialog lists the three types.""" + types = (FAMITRACKER_TYPE, BITPHASE_PRESET_TYPE, NSF_TYPE) + offered: List[Tuple[str, ...]] = [] + + def export_picking(screen: Screen, type_key: str, extension: str) -> Path: + stem = folder() / PAD + screen.answer_next_save_as(stem, screen.words(type_key)) + + export_from_the_menu(screen, PAD) + + path = stem.with_suffix(extension) + written(screen, path) + request = screen.dialog_requests()[-1] + assert request.title == screen.words(EXPORT_TITLE) + assert request.suggested_name == PAD + offered.append(tuple(file_filter.name for file_filter in request.filters)) + return path + + def the_nsf_type_writes_a_program(screen: Screen) -> None: + path = export_picking(screen, NSF_TYPE, EXT_FILE_NSF) + + assert path.read_bytes().startswith(NSF_MAGIC) + assert offered[0] == tuple(screen.words(key) for key in types) + + def the_bitphase_type_writes_a_preset(screen: Screen) -> None: + path = export_picking(screen, BITPHASE_PRESET_TYPE, EXT_FILE_JSON) + + assert path.read_bytes().lstrip().startswith(JSON_OPENING) + + def the_famitracker_type_writes_an_instrument_named_after_the_file(screen: Screen) -> None: + path = export_picking(screen, FAMITRACKER_TYPE, EXT_FILE_INSTRUMENT) + + assert path.read_bytes().startswith(FTI_MAGIC) + assert read_fti(path).name == PAD + + def a_file_renamed_in_the_dialog_renames_the_instrument(screen: Screen) -> None: + path = folder() / f"{RENAMED}{EXT_FILE_INSTRUMENT}" + screen.answer_next_dialog(DialogKind.SAVE, path) + + export_from_the_menu(screen, PAD) + + written(screen, path) + assert read_fti(path).name == RENAMED + + screen.scenario( + the_nsf_type_writes_a_program, + the_bitphase_type_writes_a_preset, + the_famitracker_type_writes_an_instrument_named_after_the_file, + a_file_renamed_in_the_dialog_renames_the_instrument, + leaving_asks_nothing, + ).run() diff --git a/tests/screens/exports/instruments/from_a_channel/__init__.py b/tests/screens/exports/instruments/from_a_channel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py b/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py new file mode 100644 index 000000000..7345a406c --- /dev/null +++ b/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py @@ -0,0 +1,133 @@ +from typing import Dict, Final, List, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.naming import instrument_slice_name +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.exports import leaving_asks_nothing +from tests.suite.screens.steps.reconstructions import voice_title +from tests.suite.screens.steps.sequencer import open_voice, open_voice_menu +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +INSTRUMENT_FROM: Final[str] = "sequencer.voices.label.context_instrument_from" +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] + + +class TestTheSubmenuNamesTheChannelsThatPlay: + """New instrument from names each channel a sample plays, one channel among them; a hand-written voice + greys it. + + The menus of the three-channel sample, the one-channel sample and the hand-written voice are read in + turn. + """ + + def test_each_channel_one_channel_and_none(self, screen: Screen) -> None: + """The submenu lists each playing channel, and the hand-written voice shows a greyed entry.""" + menu = screen.context_menu + + def a_sample_of_three_channels_names_each(screen: Screen) -> None: + open_voice_menu(screen, LINE) + + assert menu.submenu(screen.words(INSTRUMENT_FROM)) == [ + (screen.channel_words(channel), True) for channel in LINE_CHANNELS + ] + menu.dismiss() + + def a_sample_of_one_channel_still_names_it(screen: Screen) -> None: + open_voice_menu(screen, BASS_VOICE) + + assert menu.submenu(screen.words(INSTRUMENT_FROM)) == [(screen.channel_words(ChannelName.TRIANGLE), True)] + assert screen.words(INSTRUMENT_FROM) not in menu.labels() + menu.dismiss() + + def a_hand_written_voice_greys_it(screen: Screen) -> None: + open_voice_menu(screen, PAD) + + assert screen.words(INSTRUMENT_FROM) not in menu.submenus() + entry = next(entry for entry in menu.entries() if entry.label == screen.words(INSTRUMENT_FROM)) + assert not entry.enabled + menu.dismiss() + + screen.scenario( + a_sample_of_three_channels_names_each, + a_sample_of_one_channel_still_names_it, + a_hand_written_voice_greys_it, + leaving_asks_nothing, + ).run() + + +class TestTakingAChannel: + """Taking a channel adds ` ()` carrying that channel's envelopes and opens it; one + undo takes it away and leaves the sample as it was. + + The envelopes of each channel of the line are read first. Each channel is then taken from the menu, + checked and undone on the History card, since the note keys of the instrument left open take Ctrl+Z + (bugs-and-todos § Bugs). At the end the line plays the same envelopes and the project title shows no + unsaved change. + """ + + def test_each_channel_of_the_line(self, screen: Screen) -> None: + """Each taken channel is listed, opened with the envelopes it played, and gone after one undo.""" + voices = screen.sequencer.voices + instruments = screen.reconstructions.instruments + played: Dict[ChannelName, str] = {} + + def read_what_each_channel_plays(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + lambda: instruments.tab_shown(ChannelName.NOISE), + bool, + description="the line's channels", + ) + + for channel in LINE_CHANNELS: + played[channel] = instruments.envelope(channel, FeatureKey.VOLUME) + + def taking(channel: ChannelName) -> None: + name = instrument_slice_name(LINE, channel) + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in(screen.words(INSTRUMENT_FROM), screen.channel_words(channel)) + + screen.expect(voices.names, (VOICES + [name]).__eq__, description=f"{name} listed") + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + name.__eq__, + description=f"{name} open", + ) + assert screen.tabs.front() == Tab.RECONSTRUCTIONS + assert instruments.offers_audition() + assert instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == played[channel] + + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.history.undo() + + screen.expect(voices.names, VOICES.__eq__, description=f"{name} taken away") + + def take_each_channel_and_undo_it(screen: Screen) -> None: + for channel in LINE_CHANNELS: + taking(channel) + + def the_line_plays_as_it_did(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + lambda: instruments.tab_shown(ChannelName.NOISE), + bool, + description="the line's channels", + ) + + assert {channel: instruments.envelope(channel, FeatureKey.VOLUME) for channel in LINE_CHANNELS} == played + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, 0, LINE, unsaved=False).__eq__, + description="the project as it was", + ) + + screen.scenario( + read_what_each_channel_plays, + take_each_channel_and_undo_it, + the_line_plays_as_it_did, + leaving_asks_nothing, + ).run() diff --git a/tests/screens/exports/instruments/import_files/__init__.py b/tests/screens/exports/instruments/import_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/import_files/cases.py b/tests/screens/exports/instruments/import_files/cases.py new file mode 100644 index 000000000..aeac59ce5 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/cases.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +READ_FAILED: Final[str] = "Failed to read an instrument from" +NO_FILE: Final[str] = "No instrument file at" +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and hold no instrument.\n" +HALF: Final[int] = 2 + + +@dataclass(frozen=True) +class BrokenFile: + """A file the import meets broken: how it is laid in the home and what the application logs. + + Attributes: + lay: Writes the broken file at the first path, given the path of a whole exported file. + logged: The start of the error the application logs for this file. + not_found: Whether the application answers with its file-not-found message. + """ + + name: str + lay: Callable[[Path, Path], None] + logged: str + not_found: bool + + +def foreign(destination: Path, _exported: Path) -> None: + """Writes bytes of another program in place of an instrument.""" + destination.write_bytes(FOREIGN_BYTES) + + +def truncated(destination: Path, exported: Path) -> None: + """Writes the first half of the exported file.""" + content = exported.read_bytes() + destination.write_bytes(content[: len(content) // HALF]) + + +def missing(_destination: Path, _exported: Path) -> None: + """Leaves the file absent, so the import finds it gone.""" + + +BROKEN_FILES: Final[Tuple[BrokenFile, ...]] = ( + BrokenFile(name="Foreign", lay=foreign, logged=READ_FAILED, not_found=False), + BrokenFile(name="Truncated", lay=truncated, logged=READ_FAILED, not_found=False), + BrokenFile(name="Missing", lay=missing, logged=NO_FILE, not_found=True), +) diff --git a/tests/screens/exports/instruments/import_files/constants.py b/tests/screens/exports/instruments/import_files/constants.py new file mode 100644 index 000000000..d884c1828 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/constants.py @@ -0,0 +1,8 @@ +from typing import Final, List + +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +IMPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.import_instrument" +KEPT: Final[str] = "Kept" +FOLDER: Final[str] = "kept" +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] diff --git a/tests/screens/exports/instruments/import_files/steps.py b/tests/screens/exports/instruments/import_files/steps.py new file mode 100644 index 000000000..cf8b34571 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/steps.py @@ -0,0 +1,46 @@ +import operator +from pathlib import Path +from typing import List, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.screens.exports.instruments.import_files.constants import FOLDER, IMPORT_INSTRUMENT, KEPT +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice_menu +from tests.suite.screens.views.history import HistoryLine, HistorySegment +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT +from tests.suite.screens.worlds.songs import PAD + + +def kept_folder() -> Path: + """Returns the folder of the home that keeps the exported instrument files, created when missing.""" + path = Path.cwd() / FOLDER + path.mkdir(parents=True, exist_ok=True) + return path + + +def choose_import(screen: Screen) -> None: + """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.voices.right_click_below_the_rows() + screen.expect(screen.context_menu.is_shown, bool, description="the list's menu") + screen.context_menu.choose(screen.words(IMPORT_INSTRUMENT)) + + +def export_kept(screen: Screen) -> Path: + """Exports the hand-written voice from its menu to the kept folder under the name :data:`KEPT`.""" + path = kept_folder() / f"{KEPT}{EXT_FILE_INSTRUMENT}" + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, path) + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + screen.expect(notice.is_shown, bool, description="the notice of the export") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return path + + +def segments(lines: Tuple[HistoryLine, ...]) -> List[Tuple[HistorySegment, ...]]: + """Returns the segments of each history line, one tuple per line.""" + return [tuple(line.segments) for line in lines] diff --git a/tests/screens/exports/instruments/import_files/test_broken_files.py b/tests/screens/exports/instruments/import_files/test_broken_files.py new file mode 100644 index 000000000..dce776478 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/test_broken_files.py @@ -0,0 +1,59 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.screens.exports.instruments.import_files.cases import BROKEN_FILES, BrokenFile +from tests.screens.exports.instruments.import_files.constants import VOICES +from tests.screens.exports.instruments.import_files.steps import choose_import, export_kept, kept_folder, segments +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.exports import leaving_asks_nothing +from tests.suite.screens.views.history import HistorySegment + +NOT_FOUND: Final[str] = "sequencer.voices.message.instrument_not_found" + + +class TestABrokenInstrumentFile: + """A file that is no instrument, a truncated one and a missing one each give a message naming the + trouble, and leave the voices and the history as they stood. + + A whole instrument is exported first. Each broken file is then chosen on the import dialog, the + message is read and dismissed, and the voices and the history match their state before the import. + """ + + def test_each_is_reported_and_changes_nothing(self, screen: Screen) -> None: + """Each broken file is reported, and the voices and the history stay as they were.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + + def export_a_whole_file(screen: Screen) -> None: + exported.append(export_kept(screen)) + before.append(segments(history.lines())) + + def importing(broken: BrokenFile) -> None: + path = kept_folder() / f"{broken.name}{EXT_FILE_INSTRUMENT}" + broken.lay(path, exported[0]) + notice = screen.file_not_found_notice if broken.not_found else screen.error_notice + screen.answer_next_dialog(DialogKind.OPEN, path) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description=f"the message about {broken.name}") + screen.claim_error(broken.logged) + if broken.not_found: + assert screen.words(NOT_FOUND) in notice.words() + assert str(path) in notice.words() + + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the message gone") + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def import_each_broken_file(screen: Screen) -> None: + for broken in BROKEN_FILES: + importing(broken) + + screen.scenario(export_a_whole_file, import_each_broken_file, leaving_asks_nothing).run() diff --git a/tests/screens/exports/instruments/import_files/test_import.py b/tests/screens/exports/instruments/import_files/test_import.py new file mode 100644 index 000000000..c4e253618 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/test_import.py @@ -0,0 +1,201 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.exports.instruments.import_files.constants import KEPT, VOICES +from tests.screens.exports.instruments.import_files.steps import choose_import, export_kept, segments +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.exports import leaving_asks_nothing +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.views.history import HistorySegment +from tests.suite.screens.worlds.songs import PAD, RELEASING_INSTRUMENT, releasing_instrument + +IMPORT_TITLE: Final[str] = "sequencer.voices.title.import_instrument_dialog" +NO_PROJECT_TITLE: Final[str] = "global.dialog.title.no_project_open" +NO_PROJECT_MESSAGE: Final[str] = "global.dialog.message.no_project_open" +IMPORTED_TITLE: Final[str] = "sequencer.voices.title.instrument_imported" +IMPORTED_TEMPLATE: Final[str] = "sequencer.voices.template.instrument_omissions" +RELEASE_POINT: Final[str] = "sequencer.voices.label.omission_release_point" +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SETTLING_FRAMES: Final[int] = 20 + + +class TestImportWithNoProjectOpen: + """Import instrument... with no project open says so and asks for no file; with a project open, it asks + for one. + + The project is closed, Import instrument... is chosen on the list's menu, and a notice says that no + project is open while the file dialog stays closed. A new project is created, and the same choice + now asks for a file and shows no notice. + """ + + def test_it_says_so_and_asks_for_no_file(self, screen: Screen) -> None: + """The notice appears with no project; with a new project the open dialog appears and the list stays + empty. + """ + voices = screen.sequencer.voices + notice = voices.no_project_notice + asked: List[int] = [] + + def close_the_project(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.project.close() + + screen.expect(voices.names, operator.not_, description="no voices listed") + asked.append(len(screen.dialog_requests())) + + def import_says_no_project_is_open(screen: Screen) -> None: + choose_import(screen) + + screen.expect(notice.is_shown, bool, description="the notice") + assert notice.prompt.title() == screen.words(NO_PROJECT_TITLE) + assert notice.words() == screen.words(NO_PROJECT_MESSAGE) + assert len(screen.dialog_requests()) == asked[0] + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + def with_a_new_project_it_asks_for_a_file(screen: Screen) -> None: + screen.project.create() + screen.answer_next_dialog(DialogKind.OPEN, None) + + choose_import(screen) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked[0] + 1).__eq__, + description="the file asked for", + ) + request = screen.dialog_requests()[-1] + assert request.kind == DialogKind.OPEN + assert request.title == screen.words(IMPORT_TITLE) + assert not notice.is_shown() + assert voices.names() == [] + + screen.scenario( + close_the_project, + import_says_no_project_is_open, + with_a_new_project_it_asks_for_a_file, + leaving_asks_nothing, + ).run() + + +class TestAnExportedVoiceComesBack: + """An instrument exported and imported again: the dialog opens where it was written, Cancel keeps + everything as it was, and the import adds one voice playing the same envelopes, with no notice, + which one undo takes away. + + The hand-written voice is exported from its menu. Cancel on the open dialog leaves the voices and + the history as they were. Choosing the exported file adds the voice and one history line. The new + voice shows the envelopes of the original, and one undo removes it. + """ + + def test_the_round_trip(self, screen: Screen) -> None: + """The imported voice joins the list quietly, plays the same envelopes and goes with one undo.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + instruments = screen.reconstructions.instruments + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + envelopes: List[Tuple[str, str]] = [] + + def export_the_hand_written_voice(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the hand-written voice open") + envelopes.append( + ( + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME), + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO), + ) + ) + + exported.append(export_kept(screen)) + + before.append(segments(history.lines())) + + def cancel_changes_nothing(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, None) + + choose_import(screen) + + screen.expect(lambda: screen.dialog_requests()[-1].kind == DialogKind.OPEN, bool, description="asked") + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests()[-1].initial_directory == exported[0].parent + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def the_import_adds_the_voice_quietly(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, exported[0]) + + choose_import(screen) + + screen.expect(voices.names, (VOICES + [KEPT]).__eq__, description="the voice imported") + screen.frames(SETTLING_FRAMES) + assert not voices.imported_notice.is_shown() + assert len(history.lines()) == len(before[0]) + 1 + + def it_plays_the_same_envelopes(screen: Screen) -> None: + open_voice(screen, KEPT) + + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + KEPT.__eq__, + description="the imported voice open", + ) + assert ( + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME), + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO), + ) == envelopes[0] + + def one_undo_takes_it_away(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(voices.names, VOICES.__eq__, description="the import undone") + + screen.scenario( + export_the_hand_written_voice, + cancel_changes_nothing, + the_import_adds_the_voice_quietly, + it_plays_the_same_envelopes, + one_undo_takes_it_away, + leaving_asks_nothing, + ).run() + + +class TestAFileStatingMore: + """A FamiTracker instrument stating a release point imports, and a notice names the release point. + + The file is chosen on the import dialog. The voice joins the list and the notice names the + instrument and the release point. Undo removes the voice. + """ + + def test_the_notice_names_what_it_left(self, screen: Screen) -> None: + """The imported voice is listed and the notice names the release point that the import set aside.""" + voices = screen.sequencer.voices + notice = voices.imported_notice + + def import_it(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, releasing_instrument()) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description="the notice") + assert voices.names() == VOICES + [RELEASING_INSTRUMENT] + assert notice.prompt.title() == screen.words(IMPORTED_TITLE) + words = notice.words() + assert screen.words(IMPORTED_TEMPLATE).format(name=RELEASING_INSTRUMENT) in words + assert screen.words(RELEASE_POINT) in words + notice.dismiss() + + def undo_and_leave(screen: Screen) -> None: + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, VOICES.__eq__, description="the import undone") + + screen.scenario(import_it, undo_and_leave, leaving_asks_nothing).run() diff --git a/tests/screens/exports/nsf_window/__init__.py b/tests/screens/exports/nsf_window/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/nsf_window/constants.py b/tests/screens/exports/nsf_window/constants.py new file mode 100644 index 000000000..edfcbb1ad --- /dev/null +++ b/tests/screens/exports/nsf_window/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +LEFT_OUT: Final[ChannelName] = ChannelName.NOISE +SETTLING_FRAMES: Final[int] = 20 diff --git a/tests/screens/exports/nsf_window/steps.py b/tests/screens/exports/nsf_window/steps.py new file mode 100644 index 000000000..1868b71a8 --- /dev/null +++ b/tests/screens/exports/nsf_window/steps.py @@ -0,0 +1,8 @@ +from pathlib import Path + + +def folder(name: str) -> Path: + """Creates and returns a folder of the home for the program, as a native dialog's answer would name it.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/tests/screens/exports/nsf_window/test_reaches_the_file.py b/tests/screens/exports/nsf_window/test_reaches_the_file.py new file mode 100644 index 000000000..a6ac3ec37 --- /dev/null +++ b/tests/screens/exports/nsf_window/test_reaches_the_file.py @@ -0,0 +1,156 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import ProjectExport +from sampletones_core.project import ProjectContainer +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.application import SAMPLETONES_COPYRIGHT +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.screens.exports.nsf_window.constants import LEFT_OUT, SETTLING_FRAMES +from tests.screens.exports.nsf_window.steps import folder +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.vocabulary.exports import NSF_EXPORTED, NSF_TYPE +from tests.suite.screens.worlds.songs import LOOPING_PROJECT + +NSF_TITLE: Final[str] = "global.dialog.title.export_nsf_project" +FROM_THE_START: Final[str] = "settings.nsf.label.repeat_from_start" +FROM_A_FRAME: Final[str] = "settings.nsf.label.repeat_from_frame" +HELD_SOUNDS: Final[str] = "settings.nsf.label.scheme_runs" +FULL_SEARCH: Final[str] = "settings.nsf.label.scheme_search" +TYPED_TITLE: Final[str] = "Rainy Day" +SECOND_FRAME: Final[str] = "01" +LOOP_FRAME: Final[int] = 1 + + +def write_chosen_program(destination: Path) -> None: + """Writes the program the window is set to in the scenario: noise left out, a repeat from the second frame, + held sounds stored once and the typed title, the rest as the project states it. + """ + project = ProjectContainer.load(LOOPING_PROJECT) + program = NSFProgram( + information=NSFInformation( + title=TYPED_TITLE, + artist=project.info.author, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS - {LEFT_OUT}, + loop_tick=SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(LOOP_FRAME), + scheme=CompressionScheme.RUNS, + ) + NSFBackend.stated().choosing(program).write_project(destination, ProjectExport(project=project)) + + +def write_stated_program(destination: Path) -> None: + """Writes the program the project states for itself, the one the window opens on.""" + project = ProjectContainer.load(LOOPING_PROJECT) + NSFBackend.stated().choosing(NSFProgram.for_project(project)).write_project( + destination, + ProjectExport(project=project), + ) + + +class TestTheWindowReachesTheFile: + """Each choice made in the NSF window reaches the program it writes; with every channel unticked, Export waits. + + The window opens on the project's own setup. The scenario unticks every channel, ticks all but noise, sets + the repeat, the level and the title, picks the file and presses Export. The file equals the program built from + those choices and differs from the program the project states. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LOOPING_PROJECT) + + def test_the_program_written_is_the_one_set_up(self, screen: Screen) -> None: + window = screen.exports.nsf + into = folder("program") + destination = into / f"{LOOPING_PROJECT.stem}{EXT_FILE_NSF}" + asked: List[int] = [] + + def open_the_window(screen: Screen) -> None: + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert all(window.offers(channel) and window.is_ticked(channel) for channel in ChannelName.items()) + assert window.repeat() == screen.words(FROM_THE_START) + assert window.level() == screen.words(FULL_SEARCH) + assert not window.says_a_channel_is_needed() + asked.append(len(screen.dialog_requests())) + + def with_no_channel_export_waits(screen: Screen) -> None: + for channel in ChannelName.items(): + window.tick(channel) + + screen.expect(window.says_a_channel_is_needed, bool, description="the window asking for a channel") + assert not window.can_export() + window.press_export() + screen.frames(SETTLING_FRAMES) + assert window.is_shown() + assert len(screen.dialog_requests()) == asked[0] + assert list(into.iterdir()) == [] + + def a_channel_ticked_lets_it_go(screen: Screen) -> None: + for channel in ChannelName.items(): + if channel != LEFT_OUT: + window.tick(channel) + + screen.expect(window.can_export, bool, description="Export answering") + assert not window.says_a_channel_is_needed() + assert not window.is_ticked(LEFT_OUT) + + def set_the_repeat_the_level_and_the_title(screen: Screen) -> None: + window.choose_repeat(screen.words(FROM_A_FRAME)) + window.type_loop_frame(SECOND_FRAME) + window.choose_level(screen.words(HELD_SOUNDS)) + window.type_title(TYPED_TITLE) + + screen.expect(window.title, TYPED_TITLE.__eq__, description="the title typed") + assert window.repeat() == screen.words(FROM_A_FRAME) + assert window.loop_frame() == SECOND_FRAME + assert window.level() == screen.words(HELD_SOUNDS) + + def choose_the_file(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + window.browse() + + screen.expect(lambda: len(screen.dialog_requests()), (asked[0] + 1).__eq__, description="the file asked") + request = screen.dialog_requests()[-1] + assert request.title == screen.words(NSF_TITLE) + assert request.suggested_name == destination.name + assert [file_filter.name for file_filter in request.filters] == [screen.words(NSF_TYPE)] + + def the_program_written_is_the_one_set_up(screen: Screen) -> None: + notice = screen.exports.project_notice + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(NSF_EXPORTED) in notice.words() + assert not window.is_shown() + notice.dismiss() + chosen = folder("chosen") / destination.name + stated = folder("stated") / destination.name + write_chosen_program(chosen) + write_stated_program(stated) + assert destination.read_bytes() == chosen.read_bytes() + assert destination.read_bytes() != stated.read_bytes() + + screen.scenario( + open_the_window, + with_no_channel_export_waits, + a_channel_ticked_lets_it_go, + set_the_repeat_the_level_and_the_title, + choose_the_file, + the_program_written_is_the_one_set_up, + ).run() diff --git a/tests/screens/exports/nsf_window/test_silent_channels.py b/tests/screens/exports/nsf_window/test_silent_channels.py new file mode 100644 index 000000000..fc7be1f5e --- /dev/null +++ b/tests/screens/exports/nsf_window/test_silent_channels.py @@ -0,0 +1,82 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.screens.exports.nsf_window.constants import LEFT_OUT, SETTLING_FRAMES +from tests.screens.exports.nsf_window.steps import folder +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +INSTRUMENTS_EXPORTED: Final[str] = "reconstructions.instruments.message.export_instruments_success" +SILENT_IN_THE_RECONSTRUCTION: Final[ChannelName] = ChannelName.PULSE2 + + +class TestAReconstructionsSilentChannels: + """The NSF window of a reconstruction offers only the channels it sounds; a press on a silent channel's greyed + box leaves it unticked. + + The scenario opens the window, presses the silent channel, unticks and ticks a sounding one, and writes the + program, which starts with the NSF signature. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_silent_channel_stays_out(self, screen: Screen) -> None: + window = screen.exports.nsf + destination = folder("reconstruction") / f"{PLAYABLE_RECONSTRUCTION.stem}{EXT_FILE_NSF}" + sounding = [channel for channel in ChannelName.items() if channel != SILENT_IN_THE_RECONSTRUCTION] + + def open_the_window(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.exports.export_reconstruction(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert not window.offers(SILENT_IN_THE_RECONSTRUCTION) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + assert all(window.offers(channel) and window.is_ticked(channel) for channel in sounding) + + def a_press_on_the_silent_channel_ticks_nothing(screen: Screen) -> None: + window.press_on(SILENT_IN_THE_RECONSTRUCTION) + + screen.frames(SETTLING_FRAMES) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def a_press_on_a_sounding_channel_lets_it_go_and_back(screen: Screen) -> None: + window.tick(LEFT_OUT) + screen.expect(lambda: window.is_ticked(LEFT_OUT), operator.not_, description="the noise let go") + + window.tick(LEFT_OUT) + + screen.expect(lambda: window.is_ticked(LEFT_OUT), bool, description="the noise ticked again") + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def the_program_is_written(screen: Screen) -> None: + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file") + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(INSTRUMENTS_EXPORTED) in notice.words() + assert destination.read_bytes().startswith(NSF_MAGIC) + notice.dismiss() + + screen.scenario( + open_the_window, + a_press_on_the_silent_channel_ticks_nothing, + a_press_on_a_sounding_channel_lets_it_go_and_back, + the_program_is_written, + ).run() diff --git a/tests/screens/exports/progress/__init__.py b/tests/screens/exports/progress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/progress/cases.py b/tests/screens/exports/progress/cases.py new file mode 100644 index 000000000..52279bde2 --- /dev/null +++ b/tests/screens/exports/progress/cases.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, + EXT_FILE_NSF, +) +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice_menu +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, PAD + + +def click_cancel(screen: Screen) -> None: + """Clicks Cancel in the export window.""" + screen.exports.progress.cancel() + + +def press_escape(screen: Screen) -> None: + """Presses Escape to cancel the export.""" + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + +@dataclass(frozen=True) +class ExportDoor: + """One door an export starts from: what it starts, what the window lists first, and how the user cancels it. + + Attributes: + name: The folder the door writes into, named after the door. + start: Starts the export into the folder it is given, answering the dialogs it opens. + first_stage: The stage the run reports first, which the window lists. + cancel: The user's gesture that cancels the run. + project: Whether the export writes a project, whose outcome appears in the project notice. + """ + + name: str + start: Callable[[Screen, Path], None] + first_stage: ExportStage + cancel: Callable[[Screen], None] + project: bool + + +def from_the_file_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + """Builds the start of a project export chosen from the File menu, saving as the arranged project.""" + + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{ARRANGED_PROJECT.stem}{extension}") + screen.exports.export_project(item) + + return start + + +def from_the_reconstruction_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + """Builds the start of an export chosen from the Reconstruction menu, saving as the playable reconstruction.""" + + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PLAYABLE_RECONSTRUCTION.stem}{extension}") + screen.exports.export_reconstruction(item) + + return start + + +def through_the_nsf_window(menu_start: Callable[[Screen], None], stem: str) -> Callable[[Screen, Path], None]: + """Builds the start of an NSF export: opens the window with ``menu_start``, picks the file named ``stem`` + and presses Export. + """ + + def start(screen: Screen, into: Path) -> None: + window = screen.exports.nsf + menu_start(screen) + screen.expect(window.is_shown, bool, description="the NSF window") + screen.answer_next_dialog(DialogKind.SAVE, into / f"{stem}{EXT_FILE_NSF}") + window.browse() + screen.expect( + lambda: screen.dialog_requests()[-1].answer == into / f"{stem}{EXT_FILE_NSF}", + bool, + description="the file chosen", + ) + window.export() + + return start + + +def from_the_voice_menu(screen: Screen, into: Path) -> None: + """Starts the export of one instrument from its right-click menu, saving as the instrument's name.""" + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PAD}{EXT_FILE_INSTRUMENT}") + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +PROJECT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="module", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, EXT_FILE_MODULE), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=True, + ), + ExportDoor( + name="bitphase", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_BITPHASE, EXT_FILE_BITPHASE), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=True, + ), + ExportDoor( + name="program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF), + ARRANGED_PROJECT.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=True, + ), +) + +INSTRUMENT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="instruments", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + EXT_FILE_INSTRUMENT, + ), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="presets", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, + EXT_FILE_JSON, + ), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=False, + ), + ExportDoor( + name="sample program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_reconstruction( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF + ), + PLAYABLE_RECONSTRUCTION.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="instrument", + start=from_the_voice_menu, + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), +) diff --git a/tests/screens/exports/progress/constants.py b/tests/screens/exports/progress/constants.py new file mode 100644 index 000000000..bbd376699 --- /dev/null +++ b/tests/screens/exports/progress/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +FAILED_EXPORT: Final[str] = "Failed to export to" +MODULE_EXPORTED: Final[str] = "global.dialog.message.project_exported_successfully" +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 diff --git a/tests/screens/exports/progress/steps.py b/tests/screens/exports/progress/steps.py new file mode 100644 index 000000000..56a9041c1 --- /dev/null +++ b/tests/screens/exports/progress/steps.py @@ -0,0 +1,23 @@ +import operator +from pathlib import Path + +from tests.suite.screens.screen import Screen + + +def folder(name: str) -> Path: + """Creates and returns a folder of the home for one door's files, as a native dialog's answer would name it.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def written_project(screen: Screen, exported_key: str, destination: Path) -> str: + """Waits for the notice of the project export ``exported_key`` words, dismisses it, and returns what it said.""" + notice = screen.exports.project_notice + screen.expect(notice.is_shown, bool, description=f"{destination.name} written") + words = notice.words() + assert screen.words(exported_key) in words + assert destination.is_file() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return words diff --git a/tests/screens/exports/progress/test_cancel.py b/tests/screens/exports/progress/test_cancel.py new file mode 100644 index 000000000..3f3f918a4 --- /dev/null +++ b/tests/screens/exports/progress/test_cancel.py @@ -0,0 +1,89 @@ +import operator +from typing import Dict, Final + +import pytest + +from sampletones_core.exports.stage import ExportStage +from tests.screens.exports.progress.cases import INSTRUMENT_DOORS, PROJECT_DOORS, ExportDoor +from tests.screens.exports.progress.steps import folder +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.export import FIRST_REPORT, ExportHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +STAGE_WORDS: Final[Dict[ExportStage, str]] = { + ExportStage.WALKING: "settings.export.label.stage_walking", + ExportStage.COMPRESSING: "settings.export.label.stage_compressing", + ExportStage.WRITING: "settings.export.label.stage_writing", +} + +SETTLING_FRAMES: Final[int] = 20 + + +def outcome_notice(screen: Screen, door: ExportDoor) -> Notice: + """The notice a door's export ends with: the project notice for a project, the file notice otherwise.""" + return screen.exports.project_notice if door.project else screen.exports.file_notice + + +def canceled_and_then_written(screen: Screen, hold: ExportHold, door: ExportDoor) -> None: + """Starts ``door``'s export under the hold, cancels it, and starts it again unheld, which writes. + + After the cancel the window is gone, the folder is empty and no notice appears. + """ + progress = screen.exports.progress + notice = outcome_notice(screen, door) + into = folder(door.name) + hold.hold_at(FIRST_REPORT) + + door.start(screen, into) + + screen.expect(progress.is_shown, bool, description=f"the {door.name} export under way") + screen.expect(hold.waiting, (1).__eq__, description=f"the {door.name} export held") + assert progress.stages() == (screen.words(STAGE_WORDS[door.first_stage]),) + + door.cancel(screen) + + screen.expect(progress.is_shown, operator.not_, description=f"the {door.name} export canceled") + screen.frames(SETTLING_FRAMES) + assert hold.waiting() == 0 + assert not notice.is_shown() + assert list(into.iterdir()) == [] + + hold.release() + door.start(screen, into) + + screen.expect(notice.is_shown, bool, description=f"the {door.name} export written") + assert not progress.is_shown() + assert list(into.iterdir()) != [] + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +class TestEveryExportAnswersCancel: + """Every export shows its window while it runs, and a cancel stops it with the folder left empty; run to the end, it writes. + + The user cancels by clicking Cancel on some doors and pressing Escape on others. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + def test_the_project_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_project_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in PROJECT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_project_export).run() + + def test_the_instrument_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_instrument_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in INSTRUMENT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_instrument_export).run() diff --git a/tests/screens/exports/progress/test_failure_under_window.py b/tests/screens/exports/progress/test_failure_under_window.py new file mode 100644 index 000000000..d7acbb67e --- /dev/null +++ b/tests/screens/exports/progress/test_failure_under_window.py @@ -0,0 +1,69 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_shared.paths.extensions import EXT_FILE_MODULE +from tests.screens.exports.progress.constants import FAILED_EXPORT, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.holds.export import ExportHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +MODULE_FAILED: Final[str] = "global.dialog.message.project_export_failed" +READ_ONLY: Final[int] = 0o555 +WRITABLE: Final[int] = 0o755 + + +class TestAnExportFailingUnderItsWindow: + """An export failing while its window stands closes the window and shows one error saying what failed, with the + folder left empty; once the folder takes files again, the same export writes. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_the_error_follows_the_window(self, screen: Screen, export_hold: ExportHold) -> None: + progress = screen.exports.progress + into = folder("locked") + destination = into / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_the_module_export(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + screen.expect(progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def the_folder_refuses_the_file(screen: Screen) -> None: + into.chmod(READ_ONLY) + try: + export_hold.release() + + screen.expect(screen.error_notice.is_shown, bool, description="the export failed") + finally: + into.chmod(WRITABLE) + + screen.claim_error(FAILED_EXPORT) + assert not progress.is_shown() + standing = screen.error_notice.prompt.window() + assert standing is not None + assert [window.alias for window in screen.shown_windows()] == [standing.alias] + assert screen.words(MODULE_FAILED) in screen.error_notice.words() + assert list(into.iterdir()) == [] + screen.error_notice.dismiss() + screen.expect(screen.error_notice.is_shown, operator.not_, description="the error gone") + + def the_same_export_then_writes(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(start_the_module_export, the_folder_refuses_the_file, the_same_export_then_writes).run() diff --git a/tests/screens/exports/progress/test_refusals.py b/tests/screens/exports/progress/test_refusals.py new file mode 100644 index 000000000..e11ad4bf7 --- /dev/null +++ b/tests/screens/exports/progress/test_refusals.py @@ -0,0 +1,126 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_MODULE, EXT_FILE_NSF +from tests.screens.exports.progress.constants import FAILED_EXPORT, INSTRUMENT_CHANNEL, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice_menu +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT, NSF_EXPORTED +from tests.suite.screens.worlds.songs import LINE, OVERLONG_PROJECT, TWO_TUNINGS_PROJECT + +NSF_FAILED: Final[str] = "global.dialog.message.nsf_project_export_failed" +BITPHASE_FAILED: Final[str] = "global.dialog.message.bitphase_project_export_failed" +STORED_TICK_BY_TICK: Final[str] = "settings.nsf.label.scheme_none" +EVERY_REPEAT_ONCE: Final[str] = "settings.nsf.label.scheme_search" + + +def refused(screen: Screen, failed_key: str, destination: Path) -> None: + """Waits for the error naming the export ``failed_key`` words, and checks that ``destination`` is still absent.""" + notice = screen.error_notice + screen.expect(notice.is_shown, bool, description="the export refused") + screen.claim_error(FAILED_EXPORT) + assert screen.words(failed_key) in notice.words() + assert not destination.exists() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the error gone") + + +def export_the_program(screen: Screen, level_key: str, destination: Path) -> None: + """Opens File > Export > NSF program..., picks the level ``level_key`` words and the file, and presses Export.""" + window = screen.exports.nsf + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + screen.expect(window.is_shown, bool, description="the NSF window") + window.choose_level(screen.words(level_key)) + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file chosen") + assert window.level() == screen.words(level_key) + + window.export() + + +class TestASongTooLargeForTheProgram: + """A song stored tick by tick that outgrows the room of an NSF program is refused with a message and the file + stays absent; stored with each repeat saved once, it is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=OVERLONG_PROJECT) + + def test_it_is_refused_and_then_fits(self, screen: Screen) -> None: + destination = folder("overlong") / f"{OVERLONG_PROJECT.stem}{EXT_FILE_NSF}" + + def stored_tick_by_tick_it_is_refused(screen: Screen) -> None: + export_the_program(screen, STORED_TICK_BY_TICK, destination) + + refused(screen, NSF_FAILED, destination) + + def saving_each_repeat_once_it_is_written(screen: Screen) -> None: + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + written_project(screen, NSF_EXPORTED, destination) + assert destination.read_bytes().startswith(NSF_MAGIC) + + screen.scenario(stored_tick_by_tick_it_is_refused, saving_each_repeat_once_it_is_written).run() + + +class TestTwoTunings: + """A project whose samples were converted at two tunings stops a song that sounds one tuning with a message and + writes no file; a module, which keeps each sample's own pitches, is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=TWO_TUNINGS_PROJECT) + + def test_a_song_sounding_one_tuning_stops(self, screen: Screen) -> None: + into = folder("tunings") + + def a_bitphase_project_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + refused(screen, BITPHASE_FAILED, destination) + + def a_program_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_NSF}" + + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + refused(screen, NSF_FAILED, destination) + + def a_module_is_written(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(a_bitphase_project_stops, a_program_stops, a_module_is_written).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: Export instrument... in a project of two tunings fails with no message", + ) + def test_an_instrument_export_stops_with_a_message(self, screen: Screen) -> None: + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in( + screen.words(EXPORT_INSTRUMENT), + screen.channel_words(INSTRUMENT_CHANNEL), + ) + + screen.expect(screen.error_notice.is_shown, bool, description="the export refused with a message") diff --git a/tests/screens/exports/progress/test_shortened_envelopes.py b/tests/screens/exports/progress/test_shortened_envelopes.py new file mode 100644 index 000000000..4b2e0e654 --- /dev/null +++ b/tests/screens/exports/progress/test_shortened_envelopes.py @@ -0,0 +1,135 @@ +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_MODULE +from tests.screens.exports.progress.constants import INSTRUMENT_CHANNEL, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.bitphase import parse_btp +from tests.suite.famitracker import parse_ftm +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.vocabulary.instruments import TOO_LONG +from tests.suite.screens.worlds.songs import ( + LONG_ENVELOPES_PROJECT, + LONG_ITEMS, + LONG_VOICE, + MIDDLING_ITEMS, + MIDDLING_VOICE, +) + +BITPHASE_EXPORTED: Final[str] = "global.dialog.message.bitphase_project_exported_successfully" +SHORTENED_PROJECT: Final[str] = "global.dialog.template.export_truncated" +KEPT_BY_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +KEPT_BY_BITPHASE: Final[str] = "reconstructions.instruments.template.kept_bitphase" +KEPT_BY_PRESET: Final[str] = "reconstructions.instruments.template.kept_bitphase_preset" +KEPT_SEPARATOR: Final[str] = "reconstructions.instruments.template.kept_separator" +ASIDE: Final[Point] = Point(x=0, y=0) + + +def written_for(names: List[str], voices: Tuple[str, ...]) -> int: + """Counts the instruments listed under ``names`` that were written for one of ``voices``.""" + return sum(1 for name in names if any(name == voice or name.startswith(f"{voice} (") for voice in voices)) + + +class TestShortenedEnvelopes: + """An envelope longer than a format stores shows, in its field's status, each export that cuts it and what each + keeps; a project export says how many of its instruments it shortened. + + The long voice runs past every format's limit, and the middling one past FamiTracker's alone. A format writes a + voice as one instrument or several, so the count in the notice is read against the instruments the file lists. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LONG_ENVELOPES_PROJECT) + + def test_the_status_and_the_notices_name_the_cuts(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + into = folder("shortened") + + def status_of(voice: str) -> str: + open_voice(screen, voice) + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + voice.__eq__, + description=f"{voice} open", + ) + screen.hand.move_to(ASIDE) + instruments.hover_field(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) + return screen.expect(screen.status, bool, description=f"the status of {voice}'s volume") + + def too_long(items: int, *kept: str) -> str: + return screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=items, + kept=screen.words(KEPT_SEPARATOR).join(kept), + ) + + def the_long_voice_names_every_format(screen: Screen) -> None: + status = status_of(LONG_VOICE) + + assert status == too_long( + LONG_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + screen.words(KEPT_BY_BITPHASE).format(limit=MAX_MACRO_LENGTH), + screen.words(KEPT_BY_PRESET).format(limit=MAX_MACRO_LENGTH), + ) + assert instruments.field_theme(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == TAG_GLOBAL_THEME_INPUT_WARNING + + def the_middling_voice_names_famitracker_alone(screen: Screen) -> None: + status = status_of(MIDDLING_VOICE) + + assert status == too_long( + MIDDLING_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + + def a_bitphase_project_shortens_one(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + words = written_project(screen, BITPHASE_EXPORTED, destination) + names = [instrument.name for instrument in parse_btp(destination.read_bytes(), []).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE,)), + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ITEMS, + ) + in words + ) + + def a_module_shortens_both(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + words = written_project(screen, MODULE_EXPORTED, destination) + names = [instrument.name for instrument in parse_ftm(destination.read_bytes()).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE, MIDDLING_VOICE)), + frames=MAX_SEQUENCE_ITEMS, + source_frames=LONG_ITEMS, + ) + in words + ) + + screen.scenario( + the_long_voice_names_every_format, + the_middling_voice_names_famitracker_alone, + a_bitphase_project_shortens_one, + a_module_shortens_both, + ).run() diff --git a/tests/screens/instructions/__init__.py b/tests/screens/instructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/instructions/conftest.py b/tests/screens/instructions/conftest.py new file mode 100644 index 000000000..8a089322a --- /dev/null +++ b/tests/screens/instructions/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import converting_world + + +@pytest.fixture +def world() -> World: + """The home every Instructions scenario starts in: a small library built for one-worker settings.""" + return converting_world(()) diff --git a/tests/screens/instructions/test_library.py b/tests/screens/instructions/test_library.py new file mode 100644 index 000000000..bd59d2563 --- /dev/null +++ b/tests/screens/instructions/test_library.py @@ -0,0 +1,71 @@ +from functools import partial +from typing import Dict, Final, Tuple + +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName, GeneratorName +from tests.suite.screens.dearpygui.items.colors import read_theme, read_theme_colors +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.instructions import load_library +from tests.suite.screens.worlds.home import one_worker_config + +GENERATOR_CHANNELS: Final[Dict[GeneratorName, ChannelName]] = { + GeneratorName.PULSE: ChannelName.PULSE1, + GeneratorName.TRIANGLE: ChannelName.TRIANGLE, + GeneratorName.NOISE: ChannelName.NOISE, +} + + +def line_color(screen: Screen, line: str) -> Tuple[float, ...]: + """The color the theme bound to ``line`` draws it in.""" + + def read() -> Tuple[float, ...]: + theme = read_theme(line) + assert theme is not None, f"no theme is bound to '{line}'" + return read_theme_colors(theme)[0] + + return screen.bridge.ask(read) + + +def channel_colors(screen: Screen, channel: ChannelName) -> Tuple[Tuple[float, ...], ...]: + """The colors of the theme that every view naming ``channel`` paints from.""" + return screen.bridge.ask(lambda: read_theme_colors(CHANNEL_THEME_TAGS[channel])) + + +class TestTheGeneratorsOfALibrary: + """A library lists a row per generator, each drawing its fragment's waveform and spectrum in its channel's + color. + + The library loads and shows a row per generator. Each row is clicked in turn, and its waveform draws one + line in a color of its channel's theme. The three generators end with three different colors. + """ + + def test_each_generator_draws_in_its_channels_color(self, screen: Screen) -> None: + """Each generator's row draws a waveform and a spectrum in its own channel's color.""" + instructions = screen.instructions + tree = instructions.library.tree + waveform = instructions.waveform + rows: Dict[GeneratorName, Item] = {} + + def a_row_per_generator(screen: Screen) -> None: + load_library(screen, one_worker_config()) + + for generator in GeneratorName: + rows[generator] = screen.expect_item( + partial(tree.generator_row, generator), description=f"the {generator} row" + ) + + def each_draws_in_its_color(screen: Screen) -> None: + colors: Dict[GeneratorName, Tuple[float, ...]] = {} + for generator, row in rows.items(): + tree.click(row) + + lines = screen.expect(waveform.series, bool, description=f"the {generator} waveform") + assert len(lines) == 1 + screen.expect(instructions.spectrum_bands, bool, description=f"the {generator} spectrum") + colors[generator] = line_color(screen, lines[0]) + assert colors[generator] in channel_colors(screen, GENERATOR_CHANNELS[generator]) + + assert len(set(colors.values())) == len(GeneratorName) + + screen.scenario(a_row_per_generator, each_draws_in_its_color).run() diff --git a/tests/screens/instructions/test_waveform.py b/tests/screens/instructions/test_waveform.py new file mode 100644 index 000000000..097efa08a --- /dev/null +++ b/tests/screens/instructions/test_waveform.py @@ -0,0 +1,58 @@ +from functools import partial +from typing import Final + +from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY +from sampletones_core.constants.enums import GeneratorName +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.instructions import load_library +from tests.suite.screens.vocabulary.playback import PAUSE +from tests.suite.screens.worlds.home import one_worker_config + +LISTENING_FRAMES: Final[int] = 30 +ZOOM_NOTCHES: Final[int] = 3 + + +def show_the_pulse(screen: Screen) -> None: + """Loads the library, clicks the pulse row and waits until its waveform is drawn.""" + instructions = screen.instructions + tree = instructions.library.tree + load_library(screen, one_worker_config()) + row = screen.expect_item(partial(tree.generator_row, GeneratorName.PULSE), description="the pulse row") + + tree.click(row) + + screen.expect(instructions.waveform.series, bool, description="the pulse drawn") + + +class TestTheFragmentWaveform: + """A click on the fragment's waveform sounds it, and a drag across a narrowed view sounds nothing. + + A fragment lasts a few frames, so the Playback menu's entry is recorded on every frame the gesture spans. + The pulse is shown and zoomed in; the drag leaves the entry away from Pause, and the click brings Pause + up. + """ + + def test_a_click_sounds_it_and_a_drag_does_not(self, screen: Screen) -> None: + """The Playback entry reads Pause during the click and stays away from it during the drag.""" + waveform = screen.instructions.waveform + playing = screen.words(PAUSE) + + def a_drag_sounds_nothing(screen: Screen) -> None: + show_the_pulse(screen) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + with screen.record(partial(read_label, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) as recording: + waveform.drag(0.6, 0.4) + screen.frames(LISTENING_FRAMES) + + assert playing not in recording.values() + + def a_click_sounds_it(screen: Screen) -> None: + with screen.record(partial(read_label, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) as recording: + waveform.click(0.5) + screen.frames(LISTENING_FRAMES) + + assert playing in recording.values() + + screen.scenario(a_drag_sounds_nothing, a_click_sounds_it).run() diff --git a/tests/screens/interface/__init__.py b/tests/screens/interface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/cards/__init__.py b/tests/screens/interface/cards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/cards/test_every_card_folds.py b/tests/screens/interface/cards/test_every_card_folds.py new file mode 100644 index 000000000..b2cd0aa54 --- /dev/null +++ b/tests/screens/interface/cards/test_every_card_folds.py @@ -0,0 +1,114 @@ +import operator +from typing import Dict, Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.compose import TAG_SEPARATOR +from sampletones_application.tags.general import SUF_COLLAPSE_STRIP +from sampletones_application.tags.sequencer import TAG_SEQUENCER_HISTORY_PANEL +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.main import Card +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +FILL_CARDS: Final[Tuple[str, ...]] = (TAG_SEQUENCER_HISTORY_PANEL,) +TABS: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS) +SETTLING_FRAMES: Final[int] = 8 +STRIP_ENDING: Final[str] = f"{TAG_SEPARATOR}{SUF_COLLAPSE_STRIP}" + + +def cards_on(tab: Tab) -> List[str]: + """The tags of the cards shown on ``tab`` that fold. Runs on the render thread.""" + cards: List[str] = [] + for item in dpg.get_all_items(): + alias = str(dpg.get_item_alias(item)) + if alias.startswith(f"{tab}{TAG_SEPARATOR}") and alias.endswith(STRIP_ENDING): + card = alias[: -len(STRIP_ENDING)] + if read_item(card).shown: + cards.append(card) + + return sorted(cards) + + +def box(screen: Screen, item: str) -> Rect: + """The rectangle ``item`` fills on the screen.""" + rect = screen.bridge.ask(lambda: read_item(item).rect) + assert rect is not None, f"{item} stands nowhere" + return rect + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +class TestEveryCardFoldsOntoItsBar: + """Every card folds onto its bar and unfolds back to where it stood. + + A card folding down keeps its bar and the card's padding around it, at its top edge or, for a card + filling its column, at its bottom. A card folding sideways keeps its rail. The scenario opens a + reconstruction, visits each tab, folds every card on it, unfolds it, and expects the same rectangle. + """ + + def test_each_card_on_each_tab(self, screen: Screen) -> None: + folded: Dict[str, List[str]] = {} + + def folding_down(card: Card) -> None: + before = box(screen, card.tag) + card.toggle() + screen.expect(card.is_collapsed, bool, description=f"{card.tag} folded") + screen.frames(SETTLING_FRAMES) + + collapsed = box(screen, card.tag) + strip = box(screen, card.strip) + padding = strip.y - collapsed.y + assert collapsed.height == strip.height + 2 * padding, f"{card.tag}: {collapsed} around {strip}" + if card.tag in FILL_CARDS: + assert collapsed.y + collapsed.height == before.y + before.height, f"{card.tag} left its bottom" + else: + assert collapsed.y == before.y, f"{card.tag} left its top" + + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description=f"{card.tag} unfolded") + screen.frames(SETTLING_FRAMES) + assert box(screen, card.tag) == before, f"{card.tag} came back elsewhere" + + def folding_sideways(card: Card) -> None: + before = box(screen, card.tag) + card.toggle() + screen.expect(card.is_collapsed, bool, description=f"{card.tag} folded") + screen.frames(SETTLING_FRAMES) + + collapsed = box(screen, card.tag) + rail = box(screen, card.rail) + assert collapsed.height == before.height, f"{card.tag} changed its height folding sideways" + assert collapsed.x <= rail.x and rail.x + rail.width <= collapsed.x + collapsed.width, f"{card.tag}" + assert collapsed.width < before.width + + card.unfold_from_the_rail() + screen.expect(card.is_collapsed, operator.not_, description=f"{card.tag} unfolded") + screen.frames(SETTLING_FRAMES) + assert box(screen, card.tag) == before, f"{card.tag} came back elsewhere" + + def fold_every_card(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in TABS: + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) + folded[tab] = screen.bridge.ask(lambda: cards_on(tab)) + assert folded[tab], f"no card folds on {tab}" + for tag in folded[tab]: + card = screen.main.card(tag) + if card.folds_sideways(): + folding_sideways(card) + else: + folding_down(card) + + screen.scenario(fold_every_card).run() diff --git a/tests/screens/interface/conftest.py b/tests/screens/interface/conftest.py new file mode 100644 index 000000000..14be9d912 --- /dev/null +++ b/tests/screens/interface/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import sequencer_world + + +@pytest.fixture +def world() -> World: + """The home every interface scenario starts in: an arranged project and reconstructions that play.""" + return sequencer_world() diff --git a/tests/screens/interface/dialogs/__init__.py b/tests/screens/interface/dialogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/dialogs/cases.py b/tests/screens/interface/dialogs/cases.py new file mode 100644 index 000000000..4870e7ead --- /dev/null +++ b/tests/screens/interface/dialogs/cases.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_NSF_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) + + +@dataclass(frozen=True) +class MenuDialog: + """A dialog a menu entry opens. + + Attributes: + name: The dialog's name in messages. + group: The menu that holds the entry. + item: The entry that opens the dialog. + window: The tag its window stands under. + """ + + name: str + group: MenuElements + item: MenuElements + window: str + + +DIALOGS: Final[Tuple[MenuDialog, ...]] = ( + MenuDialog( + "Display settings", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, + TAG_SETTINGS_DISPLAY_WINDOW, + ), + MenuDialog( + "Keyboard shortcuts", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + ), + MenuDialog( + "Audio settings", + MenuElements.GROUP_PLAYBACK, + MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, + TAG_SETTINGS_AUDIO_WINDOW, + ), + MenuDialog( + "Project properties", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_PROJECT_PROPERTIES, + TAG_SETTINGS_PROPERTIES_WINDOW, + ), + MenuDialog( + "Render song", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_RENDER_SONG, + TAG_SETTINGS_RENDER_WINDOW, + ), + MenuDialog( + "NSF program", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_EXPORT_NSF, + TAG_SETTINGS_NSF_WINDOW, + ), + MenuDialog( + "About", + MenuElements.GROUP_HELP, + MenuElements.ITEM_HELP_ABOUT, + TAG_GLOBAL_DIALOG_ABOUT, + ), +) diff --git a/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py b/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py new file mode 100644 index 000000000..b5d5202aa --- /dev/null +++ b/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py @@ -0,0 +1,88 @@ +from functools import partial +from typing import Final, List, Optional + +import pytest + +from tests.screens.interface.dialogs.cases import DIALOGS, MenuDialog +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import read_windows +from tests.suite.screens.dearpygui.items.viewport import read_viewport +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +STILL_FRAMES: Final[int] = 20 +CENTER_TOLERANCE: Final[float] = 1.0 +HALF: Final[float] = 2.0 +SETTLING_DRAWN_FRAMES: Final[int] = 2 + + +def dialog_box(window: str) -> Optional[Rect]: + """The box of the shown window standing under ``window``, if one stands. Runs on the render thread.""" + return next( + (reading.rect for reading in read_windows() if reading.shown and is_tag_within(reading.alias, window)), + None, + ) + + +def is_centered(box: Rect, viewport: Rect) -> bool: + """Whether the center of ``box`` lies within a pixel of the center of ``viewport``.""" + return ( + abs(box.x + box.width / HALF - viewport.width / HALF) <= CENTER_TOLERANCE + and abs(box.y + box.height / HALF - viewport.height / HALF) <= CENTER_TOLERANCE + ) + + +def is_inside(box: Rect, viewport: Rect) -> bool: + """Whether ``box`` lies wholly inside ``viewport``.""" + return box.x >= 0 and box.y >= 0 and box.x + box.width <= viewport.width and box.y + box.height <= viewport.height + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestEveryDialogOpensCentered: + """On its first open of the session, every dialog a menu opens stands centered in the window and + inside it, in one place on every frame from its third drawn frame on. + + A dialog whose content settles its height takes two frames to measure itself, as `dialogs.md` + states, so the rule holds from the frame after those. The scenario opens each dialog from its menu + while recording its box, then closes it with Escape. + """ + + def test_each_dialog_on_its_first_open(self, screen: Screen) -> None: + def opening(dialog: MenuDialog) -> None: + with screen.record(partial(dialog_box, dialog.window)) as recording: + screen.menu.choose(dialog.group, dialog.item) + screen.expect( + lambda: screen.bridge.ask(partial(dialog_box, dialog.window)) is not None, + bool, + description=dialog.name, + ) + screen.frames(STILL_FRAMES) + + boxes: List[Rect] = [box for box in recording.values() if box is not None][SETTLING_DRAWN_FRAMES:] + viewport = screen.bridge.ask(read_viewport) + assert boxes, f"{dialog.name} was drawn for {SETTLING_DRAWN_FRAMES} frames or fewer" + assert all(box == boxes[0] for box in boxes), f"{dialog.name} moved: {boxes}" + assert is_inside(boxes[0], viewport), f"{dialog.name} at {boxes[0]} in {viewport}" + assert is_centered(boxes[0], viewport), f"{dialog.name} at {boxes[0]} in {viewport}" + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.expect( + lambda: screen.bridge.ask(partial(dialog_box, dialog.window)) is None, + bool, + description=f"{dialog.name} closed", + ) + + def open_each_dialog_once(screen: Screen) -> None: + for dialog in DIALOGS: + opening(dialog) + + screen.scenario(open_each_dialog_once).run() diff --git a/tests/screens/interface/display/__init__.py b/tests/screens/interface/display/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/display/constants.py b/tests/screens/interface/display/constants.py new file mode 100644 index 000000000..0055f2b9c --- /dev/null +++ b/tests/screens/interface/display/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SIZE_SEPARATOR: Final[str] = "x" diff --git a/tests/screens/interface/display/steps.py b/tests/screens/interface/display/steps.py new file mode 100644 index 000000000..d0836a19b --- /dev/null +++ b/tests/screens/interface/display/steps.py @@ -0,0 +1,46 @@ +import operator +from typing import Tuple + +from sampletones_shared.display import Resolution +from tests.screens.interface.display.constants import SIZE_SEPARATOR +from tests.suite.screens.dearpygui.items.viewport import read_viewport, read_viewport_decorated +from tests.suite.screens.screen import Screen + + +def size_named(label: str) -> Resolution: + """The window size a Resolution entry names, written width first.""" + width, height = label.split(SIZE_SEPARATOR) + return Resolution(width=int(width), height=int(height)) + + +def window_size(screen: Screen) -> Tuple[int, int]: + """The width and height of the window in pixels.""" + viewport = screen.bridge.ask(read_viewport) + return round(viewport.width), round(viewport.height) + + +def framed(screen: Screen) -> bool: + """Whether the window has its frame.""" + return screen.bridge.ask(read_viewport_decorated) + + +def open_display_settings(screen: Screen) -> None: + """Opens Display settings and waits for it to show.""" + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + +def another_size(screen: Screen) -> str: + """A size the Resolution list offers other than the one it names.""" + settings = screen.display_settings + current = settings.resolution() + return next(label for label in settings.resolutions() if label != current) + + +def kept(screen: Screen) -> None: + """Waits for the countdown a window change starts, and presses Keep.""" + settings = screen.display_settings + screen.expect(settings.countdown_shown, bool, description="the countdown") + settings.keep() + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") diff --git a/tests/screens/interface/display/test_countdown.py b/tests/screens/interface/display/test_countdown.py new file mode 100644 index 000000000..38cc004f3 --- /dev/null +++ b/tests/screens/interface/display/test_countdown.py @@ -0,0 +1,63 @@ +import operator +from typing import Final + +from tests.screens.interface.display.steps import framed, kept, open_display_settings +from tests.suite.screens.screen import Screen + +COUNTDOWN_LIMIT_SECONDS: Final[float] = 20.0 +SETTLING_FRAMES: Final[int] = 10 + + +class TestTheWindowModeCountdown: + """A window change counts down: Keep keeps it, while Revert and the count running out bring the window + back. Cancel then puts back the window the dialog opened with. + + The scenario switches to a frameless window and presses Revert, switches again and lets the + countdown run out, then switches once more, presses Keep, and cancels the dialog, expecting the + frame back. + """ + + def test_keep_revert_and_running_out(self, screen: Screen) -> None: + settings = screen.display_settings + + def revert_brings_the_frame_back(screen: Screen) -> None: + open_display_settings(screen) + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not framed(screen) + + settings.revert() + + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def running_out_brings_it_back(screen: Screen) -> None: + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + + screen.bridge.expect( + settings.countdown_shown, + operator.not_, + description="the countdown run out", + timeout=COUNTDOWN_LIMIT_SECONDS, + ) + + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def keep_keeps_it_until_cancel(screen: Screen) -> None: + settings.toggle_borderless() + + kept(screen) + + screen.frames(SETTLING_FRAMES) + assert not framed(screen) + assert settings.borderless() + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.expect(lambda: framed(screen), bool, description="the frame back") + + screen.scenario(revert_brings_the_frame_back, running_out_brings_it_back, keep_keeps_it_until_cancel).run() diff --git a/tests/screens/interface/display/test_discard_prompt.py b/tests/screens/interface/display/test_discard_prompt.py new file mode 100644 index 000000000..21b0cfbcc --- /dev/null +++ b/tests/screens/interface/display/test_discard_prompt.py @@ -0,0 +1,81 @@ +import operator +from typing import List + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + + +class TestDiscardingADisplayChange: + """Cancelling Display settings with a change pending asks before the change is thrown away. + + The scenario flips vertical sync and cancels. Keep editing returns to the dialog with the change + in place. Cancel and discard close it, and the dialog reopens with the original value. + """ + + def test_keep_editing_brings_the_dialog_back_and_discard_restores_it(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the vertical sync box flipped") + + def cancel_asks_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(prompt.is_shown, bool, description="the discard prompt") + assert not settings.is_shown() + + def keep_editing(screen: Screen) -> None: + prompt.cancel() + + screen.expect(settings.is_shown, bool, description="the dialog back on the screen") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def discard(screen: Screen) -> None: + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt again") + + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + assert not prompt.is_shown() + + def reopen_as_it_was(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + + assert settings.vsync() == original[0] + + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed at once") + + screen.scenario( + change_vertical_sync, + cancel_asks_first, + keep_editing, + discard, + reopen_as_it_was, + ).run() + + +class TestTheKeyboardOnDisplaySettings: + """The keys alone answer Display settings.""" + + def test_escape_closes_an_unchanged_dialog(self, screen: Screen) -> None: + """Escape closes a dialog that holds no change, with no question.""" + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + assert not settings.discard_prompt.is_shown() diff --git a/tests/screens/interface/display/test_discarded_change_not_written.py b/tests/screens/interface/display/test_discarded_change_not_written.py new file mode 100644 index 000000000..7764529dc --- /dev/null +++ b/tests/screens/interface/display/test_discarded_change_not_written.py @@ -0,0 +1,51 @@ +import operator +from typing import List + +from tests.screens.application.restart.steps import leave +from tests.suite.screens.screen import Screen +from tests.suite.screens.written import written_application_config + + +class TestADiscardedDisplayChange: + """A Display settings change thrown away stays out of the written settings, while one confirmed is + written. + + The scenario flips vertical sync and discards it, flips it again and confirms, leaves the + application, and expects the written settings to hold the confirmed value. + """ + + def test_leaving_writes_only_the_confirmed_change(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def discard_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def confirm_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + assert settings.vsync() == original[0] + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def leaving_writes_the_confirmed_one(screen: Screen) -> None: + leave(screen) + + assert written_application_config().display.vsync is not original[0] + + screen.scenario(discard_a_change, confirm_a_change, leaving_writes_the_confirmed_one).run() diff --git a/tests/screens/interface/display/test_fullscreen.py b/tests/screens/interface/display/test_fullscreen.py new file mode 100644 index 000000000..70ca25373 --- /dev/null +++ b/tests/screens/interface/display/test_fullscreen.py @@ -0,0 +1,67 @@ +from typing import Final, List + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.viewport import read_viewport +from tests.suite.screens.environment import SCREEN_SIZE +from tests.suite.screens.screen import Screen +from tests.suite.screens.written import written_state + +WHOLE_SCREEN: Final[Rect] = Rect(x=0, y=0, width=SCREEN_SIZE.width, height=SCREEN_SIZE.height) + + +def window(screen: Screen) -> Rect: + """The rectangle the window fills on the screen.""" + return screen.bridge.ask(read_viewport) + + +def marked_fullscreen(screen: Screen) -> bool: + """Whether View ▸ Fullscreen carries its check mark.""" + return screen.menu.is_checked(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_FULLSCREEN) + + +class TestFullscreen: + """F11 fills the screen and F11 again gives the window back its size and place. + + The View menu's mark follows the window, and leaving the application from the windowed state writes + a session that is not fullscreen. Two presses in a row end where they began. + """ + + def test_f11_fills_and_restores(self, screen: Screen) -> None: + windowed: List[Rect] = [] + + def f11_fills_the_screen(screen: Screen) -> None: + windowed.append(window(screen)) + assert windowed[0] != WHOLE_SCREEN + + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), WHOLE_SCREEN.__eq__, description="the window filling the screen") + assert marked_fullscreen(screen) + + def f11_again_restores_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), windowed[0].__eq__, description="the window back where it stood") + assert not marked_fullscreen(screen) + + def two_presses_at_once_end_where_they_began(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), windowed[0].__eq__, description="the window where it stood") + assert not marked_fullscreen(screen) + + def leaving_writes_a_window(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert not written_state().viewport.fullscreen + + screen.scenario( + f11_fills_the_screen, + f11_again_restores_it, + two_presses_at_once_end_where_they_began, + leaving_writes_a_window, + ).run() diff --git a/tests/screens/interface/display/test_kept_size_at_close.py b/tests/screens/interface/display/test_kept_size_at_close.py new file mode 100644 index 000000000..6380dfb1f --- /dev/null +++ b/tests/screens/interface/display/test_kept_size_at_close.py @@ -0,0 +1,49 @@ +from typing import List, Tuple + +import pytest + +from tests.screens.interface.display.steps import another_size, kept, open_display_settings, size_named, window_size +from tests.suite.screens.screen import Screen +from tests.suite.screens.written import written_state + + +class TestClosingWithASizeKeptButNotConfirmed: + """Closing the window while Display settings holds a size kept on the countdown but left unconfirmed + writes the size the dialog opened with. + + The session keeps that size, as it does for every setting the dialog has yet to commit. The + scenario keeps another size, closes the window, and expects the opening size in the written + session. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: closing the window writes a size Display settings never confirmed", + ) + def test_leaving_writes_the_confirmed_size(self, screen: Screen) -> None: + settings = screen.display_settings + opened: List[Tuple[int, int]] = [] + + def keep_a_size_without_confirming_it(screen: Screen) -> None: + opened.append(window_size(screen)) + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + assert screen.wait_for_exit() + viewport = written_state().viewport + assert (viewport.width, viewport.height) == opened[0] + + screen.scenario(keep_a_size_without_confirming_it, close_the_window).run() diff --git a/tests/screens/interface/display/test_saved_mode.py b/tests/screens/interface/display/test_saved_mode.py new file mode 100644 index 000000000..761e30510 --- /dev/null +++ b/tests/screens/interface/display/test_saved_mode.py @@ -0,0 +1,114 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.display import DisplayConfig +from sampletones_application.config.session.state.window import ViewportState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.display import Resolution +from tests.screens.interface.display.steps import ( + another_size, + framed, + kept, + open_display_settings, + size_named, + window_size, +) +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_state + +SEEDED_SIZE: Final[Resolution] = Resolution(width=1280, height=800) + + +def leave(screen: Screen) -> None: + """Leaves the application with the exit shortcut and waits for it to close.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestLeavingWritesTheWindowMode: + """A window size and a frameless window, kept on the countdown and confirmed with OK, are what leaving + writes. + """ + + def test_the_size_and_the_frame_are_written(self, screen: Screen) -> None: + settings = screen.display_settings + chosen: List[Resolution] = [] + + def pick_a_size_and_keep_it(screen: Screen) -> None: + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + chosen.append(size_named(label)) + screen.expect( + lambda: window_size(screen), + (chosen[0].width, chosen[0].height).__eq__, + description="the window at the size picked", + ) + + def go_frameless_and_keep_it(screen: Screen) -> None: + assert framed(screen) + + settings.toggle_borderless() + kept(screen) + + screen.expect(lambda: framed(screen), operator.not_, description="the window without its frame") + + def confirm_and_leave(screen: Screen) -> None: + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + leave(screen) + + viewport = written_state().viewport + assert (viewport.width, viewport.height) == (chosen[0].width, chosen[0].height) + assert written_application_config().display.borderless + + screen.scenario(pick_a_size_and_keep_it, go_frameless_and_keep_it, confirm_and_leave).run() + + +class TestAHomeHoldingAWindowModeShowsIt: + """A home whose session holds a window size and whose settings ask for a frameless window without + vertical sync opens the application that way, and Display settings shows it. + """ + + @pytest.fixture + def world(self) -> World: + """A home with a 1280 by 800 window at the screen's corner, frameless and without vertical sync.""" + state = screen_filling_state().model_copy( + update={"viewport": ViewportState(width=SEEDED_SIZE.width, height=SEEDED_SIZE.height, x=0, y=0)} + ) + return World( + state=state, + application_config=ApplicationConfig(display=DisplayConfig(borderless=True, vsync=False)), + config=None, + files=(), + ) + + def test_the_window_opens_as_the_session_left_it(self, screen: Screen) -> None: + """The window opens at the written size without its frame, and Display settings names the size, the + frameless mode and the vertical sync setting. + """ + settings = screen.display_settings + + def the_window_stands_as_written(screen: Screen) -> None: + assert window_size(screen) == (SEEDED_SIZE.width, SEEDED_SIZE.height) + assert not framed(screen) + + def display_settings_names_it(screen: Screen) -> None: + open_display_settings(screen) + + assert settings.resolution() == str(SEEDED_SIZE) + assert settings.borderless() + assert not settings.vsync() + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(the_window_stands_as_written, display_settings_names_it).run() diff --git a/tests/screens/interface/keybindings/__init__.py b/tests/screens/interface/keybindings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/keybindings/test_rebinding_undo.py b/tests/screens/interface/keybindings/test_rebinding_undo.py new file mode 100644 index 000000000..5925d0527 --- /dev/null +++ b/tests/screens/interface/keybindings/test_rebinding_undo.py @@ -0,0 +1,139 @@ +import operator +from functools import partial +from typing import Dict, Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER, IMGUI_ESCAPE +from tests.suite.screens.keyboard import press_combination +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race +from tests.suite.screens.vocabulary.settings import LISTENING +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD +from tests.suite.screens.written import written_application_config + +NEW_UNDO: Final[KeyCombination] = KeyCombination.parse("Ctrl+Alt+U") +RENAMED: Final[str] = "Renamed" +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def rename_the_line(screen: Screen) -> None: + """Renames the line through F2 on its row, which the history records.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.pick(screen.expect_item(partial(voices.row, LINE), description="the line's row")) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) + screen.expect(voices.names, [RENAMED, BASS_VOICE, PAD].__eq__, description="the line renamed") + + +class TestRebindingUndo: + """Undo rebound: the new keys undo and the old ones leave the document as it was, and the menu prints the new keys. + + Escape cancels a capture, keys another action holds ask before they are taken, and leaving writes + the rebind. The scenario rebinds Undo to Ctrl+Alt+U, renames a voice, tries the old keys and then + the new ones, tries to take the Redo keys and cancels, and leaves the application. + """ + + def test_the_new_keys_undo(self, screen: Screen) -> None: + settings = screen.keyboard_settings + voices = screen.sequencer.voices + original: Dict[ShortcutId, str] = {} + + def escape_cancels_a_capture(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + original.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.UNDO) + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), screen.words(LISTENING).__eq__, description="listening" + ) + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + original[ShortcutId.UNDO].__eq__, + description="the keys back", + ) + assert settings.is_shown() + + def rebind_undo(screen: Screen) -> None: + settings.listen_for(ShortcutId.UNDO) + + press_combination(screen.hand, NEW_UNDO) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + NEW_UNDO.display().__eq__, + description="the new keys", + ) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + undo = next( + entry + for entry in screen.menu.entries(MenuElements.GROUP_EDIT) + if entry.label == screen.menu.label(MenuElements.ITEM_EDIT_UNDO) + ) + assert undo.keys == NEW_UNDO.display() + + def the_old_keys_do_nothing_and_the_new_undo(screen: Screen) -> None: + rename_the_line(screen) + + press_combination(screen.hand, KeyCombination.parse(original[ShortcutId.UNDO])) + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [RENAMED, BASS_VOICE, PAD] + press_combination(screen.hand, NEW_UNDO) + screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the rename undone") + + def keys_another_action_holds_ask_first(screen: Screen) -> None: + reassign = settings.reassign_prompt + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.UNDO) + + press_combination(screen.hand, KeyCombination.parse(original[ShortcutId.REDO])) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == original[ShortcutId.REDO] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + def leaving_writes_the_rebind(screen: Screen) -> None: + forgive_the_hover_race(screen) + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_application_config().shortcuts.overrides == {ShortcutId.UNDO.value: NEW_UNDO.display()} + + screen.scenario( + escape_cancels_a_capture, + rebind_undo, + the_old_keys_do_nothing_and_the_new_undo, + keys_another_action_holds_ask_first, + leaving_writes_the_rebind, + ).run() diff --git a/tests/screens/interface/menus/__init__.py b/tests/screens/interface/menus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/menus/conftest.py b/tests/screens/interface/menus/conftest.py new file mode 100644 index 000000000..bdcbbf5fe --- /dev/null +++ b/tests/screens/interface/menus/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/menus/test_popup_width.py b/tests/screens/interface/menus/test_popup_width.py new file mode 100644 index 000000000..fc47df311 --- /dev/null +++ b/tests/screens/interface/menus/test_popup_width.py @@ -0,0 +1,71 @@ +from typing import Final, List, Tuple + +import numpy as np + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.palettes import Color, shipped_palettes, token_color +from tests.suite.screens.screen import Screen + +POPUP_TOKEN: Final[str] = "popup" +RGB: Final[int] = 3 +COLOR_TOLERANCE: Final[float] = 1.5 / 255.0 +LEFT_INSET: Final[int] = 4 +OPENING_FRAMES: Final[int] = 5 +READINGS: Final[int] = 8 +FRAMES_BETWEEN_READINGS: Final[int] = 3 + +MENUS: Final[Tuple[MenuElements, ...]] = ( + MenuElements.GROUP_FILE, + MenuElements.GROUP_EDIT, + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.GROUP_VOICE, + MenuElements.GROUP_PLAYBACK, + MenuElements.GROUP_VIEW, + MenuElements.GROUP_HELP, +) + + +def popup_right_edge(pixels: np.ndarray, header: Point, background: Color) -> int: + """The last column the popup below ``header`` paints in its background, on the rows it spans. + + The popup's rows are those its background reaches just inside its left edge, under the header. + """ + painted = np.all(np.abs(pixels[:, :, :RGB] - np.array(background[:RGB])) <= COLOR_TOLERANCE, axis=2) + column = header.x + LEFT_INSET + rows = np.nonzero(painted[:, column])[0] + rows = rows[rows > header.y] + assert rows.size, f"no popup stands under the header at {header}" + columns = np.nonzero(painted[rows, column:].any(axis=0))[0] + return int(columns.max()) + column + + +class TestMenuPopupsHoldTheirWidth: + """Every menu popup keeps its width on every frame it stands open. + + The scenario opens each menu, reads the popup's right edge on several frames, and expects one + value. + """ + + def test_each_popup_stands_still(self, screen: Screen) -> None: + background = token_color(shipped_palettes().get(DEFAULT_PALETTE_NAME), POPUP_TOKEN) + + def measuring(group: MenuElements) -> None: + screen.menu.open(group) + screen.frames(OPENING_FRAMES) + header = screen.menu.header(group) + edges: List[int] = [] + for _ in range(READINGS): + edges.append(popup_right_edge(screen.frame_pixels(), header, background)) + screen.frames(FRAMES_BETWEEN_READINGS) + + assert len(set(edges)) == 1, f"the {group} popup widened: {edges}" + screen.menu.close() + screen.frames(OPENING_FRAMES) + + def open_each_menu(screen: Screen) -> None: + for group in MENUS: + measuring(group) + + screen.scenario(open_each_menu).run() diff --git a/tests/screens/interface/menus/test_printed_keys.py b/tests/screens/interface/menus/test_printed_keys.py new file mode 100644 index 000000000..b7bf2d099 --- /dev/null +++ b/tests/screens/interface/menus/test_printed_keys.py @@ -0,0 +1,66 @@ +from typing import Final, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + +PRINTED: Final[Tuple[Tuple[MenuElements, MenuElements, ShortcutId], ...]] = ( + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT, ShortcutId.NEW_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT, ShortcutId.OPEN_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT, ShortcutId.SAVE_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS, ShortcutId.SAVE_PROJECT_AS), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES, ShortcutId.PROJECT_PROPERTIES), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_RENDER_SONG, ShortcutId.RENDER_SONG), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_CLOSE_PROJECT, ShortcutId.CLOSE_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_EXIT, ShortcutId.EXIT), + (MenuElements.GROUP_EDIT, MenuElements.ITEM_EDIT_UNDO, ShortcutId.UNDO), + (MenuElements.GROUP_EDIT, MenuElements.ITEM_EDIT_REDO, ShortcutId.REDO), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_RECONSTRUCT_FILE, + ShortcutId.RECONSTRUCT_FILE, + ), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_RECONSTRUCT_DIRECTORY, + ShortcutId.RECONSTRUCT_DIRECTORY, + ), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_OPEN, ShortcutId.OPEN_RECONSTRUCTION), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE, ShortcutId.SAVE_RECONSTRUCTION), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_SAVE_AS, + ShortcutId.SAVE_RECONSTRUCTION_AS, + ), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE, ShortcutId.CLOSE_RECONSTRUCTION), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_EXPORT_WAV, + ShortcutId.EXPORT_RECONSTRUCTION_WAV, + ), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY_FROM_START, ShortcutId.PLAY_FROM_START), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY_FROM_FRAME, ShortcutId.PLAY_FROM_FRAME), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_STOP, ShortcutId.STOP), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY, ShortcutId.TOGGLE_AUTOPLAY), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG, ShortcutId.TOGGLE_LOOP_SONG), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, ShortcutId.AUDIO_SETTINGS), + ( + MenuElements.GROUP_PLAYBACK_CHANNELS, + MenuElements.ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, + ShortcutId.UNMUTE_ALL_CHANNELS, + ), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS, ShortcutId.TOGGLE_ADVANCED_SETTINGS), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_FULLSCREEN, ShortcutId.TOGGLE_FULLSCREEN), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, ShortcutId.DISPLAY_SETTINGS), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, ShortcutId.KEYBOARD_SETTINGS), +) + + +class TestPrintedKeysFollowTheScheme: + """Each menu entry prints the keys the scheme in place gives the action it runs.""" + + def test_each_entry_prints_its_keys(self, screen: Screen) -> None: + for group, item, shortcut_id in PRINTED: + entries = {entry.label: entry.keys for entry in screen.menu.entries(group)} + assert screen.menu.label(item) in entries, f"{group} offers no {item}: {list(entries)}" + assert entries[screen.menu.label(item)] == screen.shortcut_words(shortcut_id), f"{item}" diff --git a/tests/screens/interface/palettes/__init__.py b/tests/screens/interface/palettes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/palettes/cases.py b/tests/screens/interface/palettes/cases.py new file mode 100644 index 000000000..87368155c --- /dev/null +++ b/tests/screens/interface/palettes/cases.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import Dict + +from tests.suite.screens.boundaries.highlights import HighlightPlace +from tests.suite.screens.palettes import Color + + +@dataclass(frozen=True) +class Painted: + """Every color the interface holds at one moment. + + Attributes: + held: The colors DearPyGui holds for items and themes, by item. + highlights: The highlight colors laid on the tables, by place. + """ + + held: Dict[str, Color] + highlights: Dict[HighlightPlace, Color] diff --git a/tests/screens/interface/palettes/conftest.py b/tests/screens/interface/palettes/conftest.py new file mode 100644 index 000000000..bdcbbf5fe --- /dev/null +++ b/tests/screens/interface/palettes/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/palettes/constants.py b/tests/screens/interface/palettes/constants.py new file mode 100644 index 000000000..5d68682ba --- /dev/null +++ b/tests/screens/interface/palettes/constants.py @@ -0,0 +1,7 @@ +from typing import Final, Tuple + +from sampletones_application.categories.hierarchy import Tab + +SETTLING_FRAMES: Final[int] = 10 +TAB_FRAMES: Final[int] = 10 +EVERY_TAB: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.INSTRUCTIONS, Tab.RECONSTRUCTIONS, Tab.SEQUENCER) diff --git a/tests/screens/interface/palettes/steps.py b/tests/screens/interface/palettes/steps.py new file mode 100644 index 000000000..86885aa33 --- /dev/null +++ b/tests/screens/interface/palettes/steps.py @@ -0,0 +1,23 @@ +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import EVERY_TAB, TAB_FRAMES +from tests.suite.screens.dearpygui.items.colors import read_held_colors +from tests.suite.screens.palettes import in_fractions +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + + +def painted(screen: Screen) -> Painted: + """Reads every held color and every table highlight on the screen now.""" + return Painted( + held=screen.bridge.ask(read_held_colors), + highlights={place: in_fractions(color) for place, color in screen.table_highlights().items()}, + ) + + +def show_every_tab(screen: Screen) -> None: + """Brings each tab forward once, so every panel stands built and drawn before the colors are read.""" + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in EVERY_TAB: + screen.tabs.bring_to_front(tab) + screen.frames(TAB_FRAMES) diff --git a/tests/screens/interface/palettes/test_every_held_color.py b/tests/screens/interface/palettes/test_every_held_color.py new file mode 100644 index 000000000..2e17bc021 --- /dev/null +++ b/tests/screens/interface/palettes/test_every_held_color.py @@ -0,0 +1,101 @@ +import operator +from typing import Dict, List, Tuple + +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import SETTLING_FRAMES +from tests.screens.interface.palettes.steps import painted, show_every_tab +from tests.suite.screens.palettes import Color, shared_colors, shipped_palettes +from tests.suite.screens.screen import Screen + + +def unchanged(readings: List[Painted]) -> Tuple[List[Color], List[Color]]: + """The held colors and the highlight colors that read the same in every reading.""" + held = [readings[0].held[key] for key in readings[0].held if len({reading.held[key] for reading in readings}) == 1] + highlights = [ + readings[0].highlights[place] + for place in readings[0].highlights + if len({reading.highlights[place] for reading in readings}) == 1 + ] + return held, highlights + + +def differing(after: Painted, before: Painted) -> Dict[str, Tuple[Color, Color]]: + """The held colors two readings disagree on, among the items both found drawn. + + A dialog closed between the readings takes its own items off the screen, so those are left out. + """ + common = after.held.keys() & before.held.keys() + assert len(common) > len(before.held) // 2 + return {key: (after.held[key], before.held[key]) for key in common if after.held[key] != before.held[key]} + + +class TestEveryHeldColorFollowsThePalette: + """Picking a palette repaints every color the interface holds. + + A color that no palette changes is one every palette states alike. The table highlights follow the + palette, a round trip comes back to the colors it began with, and Cancel puts back the palette + in force before the preview. + + The scenario reads the starting palette, switches to each other palette and compares readings, + returns to the first palette, then previews another one and cancels. It ends with the starting + colors on the screen. + """ + + def test_no_color_is_left_from_the_palette_before(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + readings: Dict[str, Painted] = {} + + def read_the_palette_the_application_starts_in(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + assert settings.palette() == DEFAULT_PALETTE_NAME + readings[DEFAULT_PALETTE_NAME] = painted(screen) + assert readings[DEFAULT_PALETTE_NAME].highlights + + def every_palette_repaints_what_differs(screen: Screen) -> None: + for name in catalog.names: + if name == DEFAULT_PALETTE_NAME: + continue + + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + readings[name] = painted(screen) + assert readings[name].held.keys() == readings[DEFAULT_PALETTE_NAME].held.keys() + assert readings[name].highlights.keys() == readings[DEFAULT_PALETTE_NAME].highlights.keys() + + held, highlights = unchanged(list(readings.values())) + assert set(held) <= shared_colors(catalog) + assert set(highlights) <= shared_colors(catalog) + + def the_first_palette_again_paints_as_it_began(screen: Screen) -> None: + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + + assert painted(screen) == readings[DEFAULT_PALETTE_NAME] + + def cancel_leaves_nothing_of_the_preview(screen: Screen) -> None: + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + settings.choose_palette(other) + screen.frames(SETTLING_FRAMES) + assert painted(screen) == readings[other] + + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.frames(SETTLING_FRAMES) + after = painted(screen) + assert after.highlights == readings[DEFAULT_PALETTE_NAME].highlights + assert differing(after, readings[DEFAULT_PALETTE_NAME]) == {} + + screen.scenario( + read_the_palette_the_application_starts_in, + every_palette_repaints_what_differs, + the_first_palette_again_paints_as_it_began, + cancel_leaves_nothing_of_the_preview, + ).run() diff --git a/tests/screens/interface/palettes/test_twenty_swaps.py b/tests/screens/interface/palettes/test_twenty_swaps.py new file mode 100644 index 000000000..cc3663266 --- /dev/null +++ b/tests/screens/interface/palettes/test_twenty_swaps.py @@ -0,0 +1,44 @@ +import operator +from typing import Dict, Final, List + +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import SETTLING_FRAMES +from tests.screens.interface.palettes.steps import painted, show_every_tab +from tests.suite.screens.palettes import shipped_palettes +from tests.suite.screens.screen import Screen + +SECOND_ROUND: Final[int] = 2 +ROUNDS: Final[int] = 20 + + +class TestTwentySwaps: + """Twenty round trips between two palettes add no items: the interface holds as many items after the + twentieth as after the second, and paints as it did. + """ + + def test_the_item_count_holds(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + counts: Dict[int, int] = {} + readings: List[Painted] = [] + + def swap_back_and_forth(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + readings.append(painted(screen)) + + for round_number in range(1, ROUNDS + 1): + settings.choose_palette(other) + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + counts[round_number] = screen.item_count() + + assert counts[ROUNDS] == counts[SECOND_ROUND] + assert painted(screen) == readings[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(swap_back_and_forth).run() diff --git a/tests/screens/interface/shortcuts/__init__.py b/tests/screens/interface/shortcuts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/shortcuts/conftest.py b/tests/screens/interface/shortcuts/conftest.py new file mode 100644 index 000000000..bdcbbf5fe --- /dev/null +++ b/tests/screens/interface/shortcuts/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/shortcuts/constants.py b/tests/screens/interface/shortcuts/constants.py new file mode 100644 index 000000000..e20e3358d --- /dev/null +++ b/tests/screens/interface/shortcuts/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 diff --git a/tests/screens/interface/shortcuts/steps.py b/tests/screens/interface/shortcuts/steps.py new file mode 100644 index 000000000..2baca028a --- /dev/null +++ b/tests/screens/interface/shortcuts/steps.py @@ -0,0 +1,9 @@ +from sampletones_application.categories.hierarchy import Tab +from tests.screens.interface.shortcuts.constants import SETTLING_FRAMES +from tests.suite.screens.screen import Screen + + +def on_a_tab(screen: Screen, tab: Tab) -> None: + """Brings ``tab`` forward with a click, which leaves every field free of the keyboard.""" + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) diff --git a/tests/screens/interface/shortcuts/test_acting_on_documents.py b/tests/screens/interface/shortcuts/test_acting_on_documents.py new file mode 100644 index 000000000..d020750b8 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_acting_on_documents.py @@ -0,0 +1,40 @@ +import operator + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import UNTITLED, expect_open, titled +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + + +class TestShortcutsActingOnDocuments: + """Each shortcut closing or starting a document does what its entry says. + + The scenario closes the reconstruction, closes the project, starts a new project, and leaves the + application. + """ + + def test_each_acts_as_named(self, screen: Screen) -> None: + voices = screen.sequencer.voices + reconstructions = screen.reconstructions + + def documents_close_and_a_new_project_opens(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + on_a_tab(screen, Tab.SEQUENCER) + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(reconstructions.file_line, operator.not_, description="the reconstruction closed") + + screen.press_shortcut(ShortcutId.CLOSE_PROJECT) + screen.expect(voices.names, operator.not_, description="the project closed") + + screen.press_shortcut(ShortcutId.NEW_PROJECT) + + screen.expect(screen.title, titled(screen, screen.words(UNTITLED)).__eq__, description="a new project") + + def exit_leaves(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + screen.scenario(documents_close_and_a_new_project_opens, exit_leaves).run() diff --git a/tests/screens/interface/shortcuts/test_opening_dialogs.py b/tests/screens/interface/shortcuts/test_opening_dialogs.py new file mode 100644 index 000000000..41193a181 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_opening_dialogs.py @@ -0,0 +1,110 @@ +import operator +from functools import partial +from typing import Final, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.shortcuts.constants import SETTLING_FRAMES +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import read_windows +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +DIALOG_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, str], ...]] = ( + (ShortcutId.DISPLAY_SETTINGS, TAG_SETTINGS_DISPLAY_WINDOW), + (ShortcutId.KEYBOARD_SETTINGS, TAG_SETTINGS_KEYBINDINGS_WINDOW), + (ShortcutId.AUDIO_SETTINGS, TAG_SETTINGS_AUDIO_WINDOW), + (ShortcutId.PROJECT_PROPERTIES, TAG_SETTINGS_PROPERTIES_WINDOW), + (ShortcutId.RENDER_SONG, TAG_SETTINGS_RENDER_WINDOW), +) + +FILE_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, DialogKind], ...]] = ( + (ShortcutId.SAVE_PROJECT_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_FAMITRACKER, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_BITPHASE, DialogKind.SAVE), + (ShortcutId.RECONSTRUCT_FILE, DialogKind.OPEN), + (ShortcutId.RECONSTRUCT_DIRECTORY, DialogKind.DIRECTORY), + (ShortcutId.OPEN_RECONSTRUCTION, DialogKind.OPEN), + (ShortcutId.SAVE_RECONSTRUCTION_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_RECONSTRUCTION_WAV, DialogKind.SAVE), + (ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, DialogKind.SAVE), +) + + +def window_standing(window: str) -> bool: + """Whether a window standing under ``window`` is shown. Runs on the render thread.""" + return any(reading.shown and is_tag_within(reading.alias, window) for reading in read_windows()) + + +class TestShortcutsOpeningDialogs: + """Each shortcut printed for a dialog opens that dialog, and each one printed for a file asks for that + file. + + Open project asks about the project already open first: Cancel takes the question back with no + file requested, and a second try followed by Confirm requests the file. + """ + + def test_each_opens_what_it_names(self, screen: Screen) -> None: + def each_dialog_shortcut(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, window in DIALOG_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + + screen.press_shortcut(shortcut_id) + + screen.expect(lambda: screen.bridge.ask(partial(window_standing, window)), bool, description=window) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.expect( + lambda: screen.bridge.ask(partial(window_standing, window)), + operator.not_, + description=f"{window} closed", + ) + + def each_file_shortcut(screen: Screen) -> None: + for shortcut_id, kind in FILE_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + asked = len(screen.dialog_requests()) + screen.answer_next_dialog(kind, None) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked + 1).__eq__, + description=f"the file {shortcut_id} asks for", + ) + assert screen.dialog_requests()[-1].kind == kind + + def open_project_asks_about_the_open_one_first(screen: Screen) -> None: + prompt = screen.project.replace_prompt + on_a_tab(screen, Tab.SEQUENCER) + asked = len(screen.dialog_requests()) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question about the open project") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert len(screen.dialog_requests()) == asked + screen.answer_next_dialog(DialogKind.OPEN, None) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question again") + prompt.confirm() + screen.expect( + lambda: len(screen.dialog_requests()), (asked + 1).__eq__, description="the project asked for" + ) + assert screen.dialog_requests()[-1].kind == DialogKind.OPEN + + screen.scenario(each_dialog_shortcut, each_file_shortcut, open_project_asks_about_the_open_one_first).run() diff --git a/tests/screens/interface/shortcuts/test_switching_views.py b/tests/screens/interface/shortcuts/test_switching_views.py new file mode 100644 index 000000000..2f70a92b0 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_switching_views.py @@ -0,0 +1,128 @@ +from functools import partial +from typing import Final, List, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +TAB_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, Tab], ...]] = ( + (ShortcutId.SELECT_TAB_RECONSTRUCTIONS, Tab.RECONSTRUCTIONS), + (ShortcutId.SELECT_TAB_SEQUENCER, Tab.SEQUENCER), + (ShortcutId.SELECT_TAB_INSTRUCTIONS, Tab.INSTRUCTIONS), + (ShortcutId.SELECT_TAB_MAIN, Tab.MAIN), +) + +CHANNEL_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, ChannelName], ...]] = ( + (ShortcutId.TOGGLE_CHANNEL_PULSE_1, ChannelName.PULSE1), + (ShortcutId.TOGGLE_CHANNEL_PULSE_2, ChannelName.PULSE2), + (ShortcutId.TOGGLE_CHANNEL_TRIANGLE, ChannelName.TRIANGLE), + (ShortcutId.TOGGLE_CHANNEL_NOISE, ChannelName.NOISE), +) + +CHECKED_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements, MenuElements], ...]] = ( + (ShortcutId.TOGGLE_AUTOPLAY, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY), + (ShortcutId.TOGGLE_LOOP_SONG, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG), + (ShortcutId.TOGGLE_ADVANCED_SETTINGS, MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS), +) + +FOLLOW_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements], ...]] = ( + (ShortcutId.FOLLOW_PATTERNS, MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS), + (ShortcutId.FOLLOW_OFF, MenuElements.ITEM_PLAYBACK_FOLLOW_OFF), + (ShortcutId.FOLLOW_ROWS, MenuElements.ITEM_PLAYBACK_FOLLOW_ROWS), +) + +FOLLOW_ITEMS: Final[Tuple[MenuElements, ...]] = tuple(item for _, item in FOLLOW_SHORTCUTS) + + +def checked(screen: Screen, group: MenuElements, label: str) -> bool: + """Whether the entry labeled ``label`` in the menu ``group`` carries its check mark.""" + return next(entry.checked for entry in screen.menu.entries(group) if entry.label == label) + + +def sounding(screen: Screen) -> List[bool]: + """Which channels Playback ▸ Channels marks as sounding, in channel order.""" + return [ + checked(screen, MenuElements.GROUP_PLAYBACK_CHANNELS, screen.channel_words(channel)) + for channel in ChannelName.items() + ] + + +def followed(screen: Screen) -> List[bool]: + """Which follow modes Playback ▸ Follow marks as checked, in menu order.""" + return [checked(screen, MenuElements.GROUP_PLAYBACK_FOLLOW, screen.menu.label(item)) for item in FOLLOW_ITEMS] + + +class TestShortcutsSwitchingWhatIsShown: + """Each tab, channel, mode and playback shortcut changes what its menu entry or the screen says it + changes. + + The scenario goes through the tab shortcuts, mutes and restores each channel, flips each checked + entry twice, selects each follow mode, and starts and stops playback. + """ + + def test_each_switches_its_state(self, screen: Screen) -> None: + playback = screen.sequencer.playback + + def tabs(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, tab in TAB_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(screen.tabs.front, tab.__eq__, description=f"{tab} in front") + + screen.press_shortcut(ShortcutId.NEXT_TAB) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the next tab in front") + screen.press_shortcut(ShortcutId.PREVIOUS_TAB) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the previous tab in front") + + def channels(screen: Screen) -> None: + on_a_tab(screen, Tab.SEQUENCER) + for index, (shortcut_id, channel) in enumerate(CHANNEL_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position > index for position in range(len(CHANNEL_SHORTCUTS))] + screen.expect(partial(sounding, screen), expected.__eq__, description=f"{channel} muted") + + for shortcut_id, channel in CHANNEL_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(partial(sounding, screen), all, description="every channel sounding again") + + def switches(screen: Screen) -> None: + for shortcut_id, group, item in CHECKED_SHORTCUTS: + on_a_tab(screen, Tab.SEQUENCER) + before = screen.menu.is_checked(group, item) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: screen.menu.is_checked(group, item), + (not before).__eq__, + description=f"{item} switched", + ) + screen.press_shortcut(shortcut_id) + screen.expect(lambda: screen.menu.is_checked(group, item), before.__eq__, description=f"{item} back") + + def follow_modes(screen: Screen) -> None: + for index, (shortcut_id, item) in enumerate(FOLLOW_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position == index for position in range(len(FOLLOW_ITEMS))] + screen.expect(partial(followed, screen), expected.__eq__, description=f"{item} alone checked") + + def play_and_stop(screen: Screen) -> None: + for start in (ShortcutId.PLAY, ShortcutId.PLAY_FROM_START): + screen.press_shortcut(start) + screen.expect(playback.play_entry, screen.words(PAUSE).__eq__, description=f"playing after {start}") + + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.play_entry, screen.words(PLAY).__eq__, description="stopped") + + screen.scenario(tabs, channels, switches, follow_modes, play_and_stop).run() diff --git a/tests/screens/main/__init__.py b/tests/screens/main/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/cards/__init__.py b/tests/screens/main/cards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/cards/test_card_layout.py b/tests/screens/main/cards/test_card_layout.py new file mode 100644 index 000000000..856ac4fd8 --- /dev/null +++ b/tests/screens/main/cards/test_card_layout.py @@ -0,0 +1,182 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import ( + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_CONFIG_PANEL, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_SOURCE_PANEL, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import read_scroll +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +PAST_THE_DIALOG: Final[int] = 30 +SECONDS: Final[float] = 0.2 +FREQUENCY: Final[float] = 220.0 +SAME_LINE_PIXELS: Final[float] = 1.0 + + +def box(screen: Screen, tag: str) -> Rect: + """Returns the rectangle of the item ``tag``.""" + rect = screen.bridge.ask(lambda: read_item(tag).rect) + assert rect is not None, tag + return rect + + +def bottom(rect: Rect) -> float: + """Returns the lower edge of ``rect``.""" + return rect.y + rect.height + + +def right(rect: Rect) -> float: + """Returns the right edge of ``rect``.""" + return rect.x + rect.width + + +@pytest.fixture +def world() -> World: + """The home holds thirty short voices, and the saved state shows the Advanced settings.""" + state = lived_in_world().state + assert state is not None + files = tuple( + Recording(destination=home_path(f"voice{index:02d}.wav"), seconds=SECONDS, frequency=FREQUENCY) + for index in range(PAST_THE_DIALOG) + ) + return World( + state=state.model_copy(update={"advanced_settings": True}), + application_config=None, + config=None, + files=files, + ) + + +class TestTheCardsOfTheMainTab: + """The Main tab stacks General settings, the Converter and Source settings from top to bottom, and the + Advanced card keeps General's line. + """ + + def test_the_order_and_the_shared_line(self, screen: Screen) -> None: + """The cards stand in order, General and Advanced end on one line, and Advanced lies right of + General. + """ + general = box(screen, TAG_MAIN_CONFIG_PANEL) + advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) + converter = box(screen, TAG_MAIN_CONVERTER_PANEL) + source = box(screen, TAG_MAIN_SOURCE_PANEL) + + assert general.y < converter.y < source.y + assert abs(bottom(general) - bottom(advanced)) <= SAME_LINE_PIXELS + assert right(general) < advanced.x + + def test_advanced_stands_beside_general_ending_where_the_converter_does(self, screen: Screen) -> None: + """After Advanced is put away and shown again, it stands beside General and ends at the Converter's + right edge. + """ + + def advanced_off_and_on_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + + screen.expect(screen.main.advanced.is_shown, bool, description="Advanced back") + + def the_two_side_by_side(screen: Screen) -> None: + screen.expect( + lambda: right(box(screen, TAG_MAIN_CONFIG_PANEL)) < box(screen, TAG_MAIN_ADVANCED_PANEL).x, + bool, + description="the two side by side", + ) + advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) + assert abs(right(advanced) - right(box(screen, TAG_MAIN_CONVERTER_PANEL))) <= SAME_LINE_PIXELS + + screen.scenario(advanced_off_and_on_again, the_two_side_by_side).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: General settings stops short of the Converter's edge with Advanced put away", + ) + def test_general_fills_the_row_with_advanced_put_away(self, screen: Screen) -> None: + """With Advanced put away, General ends at the Converter's right edge.""" + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + + screen.expect( + lambda: abs(right(box(screen, TAG_MAIN_CONFIG_PANEL)) - right(box(screen, TAG_MAIN_CONVERTER_PANEL))), + lambda gap: gap <= SAME_LINE_PIXELS, + description="General ending where the Converter does", + ) + + def test_collapsing_general_alone_gives_up_the_rows_height(self, screen: Screen) -> None: + """Collapsing General with Advanced put away shrinks the card and lifts the Converter to meet it.""" + general = screen.main.card(TAG_MAIN_CONFIG_PANEL) + heights: List[float] = [] + + def put_advanced_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + heights.append(box(screen, TAG_MAIN_CONFIG_PANEL).height) + + def collapse_general(screen: Screen) -> None: + general.toggle() + + screen.expect(general.is_collapsed, bool, description="General collapsed") + screen.expect( + lambda: box(screen, TAG_MAIN_CONVERTER_PANEL).y - bottom(box(screen, TAG_MAIN_CONFIG_PANEL)), + lambda gap: gap < heights[0], + description="the Converter meeting the collapsed card", + ) + collapsed = box(screen, TAG_MAIN_CONFIG_PANEL) + assert collapsed.height < heights[0] + + screen.scenario(put_advanced_away, collapse_general).run() + + +class TestAddingAfterScrollingThePick: + """A mix picked from a scrolled list closes on Add, and the card answers the next gesture. + + The checklist asks it of twelve recordings. The question fits twelve without scrolling on this + screen, so the scenario gathers as many as make it scroll. It scrolls the pick list to the end and + presses Add. A click on the first row then shows that recording on the Source card. + """ + + def test_the_question_closes_and_the_card_answers(self, screen: Screen) -> None: + """Add closes the question and the next row pick reaches the Source card.""" + main = screen.main + question = main.converter.mix_question + paths = [home_path(f"voice{index:02d}.wav") for index in range(PAST_THE_DIALOG)] + + def open_the_question_and_scroll_it(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + screen.expect( + lambda: screen.bridge.ask(lambda: read_scroll(question.tags.well)).maximum, + lambda maximum: maximum > 0, + description="the pick list scrolling", + ) + + screen.hand.scroll_to_end(question.tags.well) + + def add(screen: Screen) -> None: + question.add() + + screen.expect(question.is_shown, operator.not_, description="the question closed") + + def the_card_answers(screen: Screen) -> None: + main.converter.list.pick(paths[0]) + + screen.expect(main.source.subject, paths[0].stem.__eq__, description="the card on the row picked") + + screen.scenario(open_the_question_and_scroll_it, add, the_card_answers).run() diff --git a/tests/screens/main/gathering/__init__.py b/tests/screens/main/gathering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/gathering/conftest.py b/tests/screens/main/gathering/conftest.py new file mode 100644 index 000000000..38a382a39 --- /dev/null +++ b/tests/screens/main/gathering/conftest.py @@ -0,0 +1,59 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from tests.screens.main.gathering.constants import ( + COLLIDING, + INNER, + LOOPS, + LOOPS_HELD, + NOTES, + TAKES, + TAKES_AT_THE_TOP, + TAKES_INSIDE, +) +from tests.suite.screens.seeds.archives import WrittenBytes +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import lived_in_world + +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.2 +TONE_FREQUENCY: Final[float] = 220.0 +NOTE_TEXT: Final[bytes] = b"A folder holding words, not sounds.\n" + + +def recording(path: Path, seconds: float) -> HomeFile: + """Returns a seeded tone recording of ``seconds`` to be written at ``path``.""" + return Recording(destination=path, seconds=seconds, frequency=TONE_FREQUENCY) + + +def gathering_world() -> World: + """Returns the lived-in world plus two long recordings, the Takes folder with a subfolder, the Loops + folder, three recordings with colliding names and a Notes folder holding only a text file. + """ + files: List[HomeFile] = [ + recording(home_path(KICK), PLAYED_SECONDS), + recording(home_path(SNARE), PLAYED_SECONDS), + *(recording(home_path(TAKES) / name, SHORT_SECONDS) for name in TAKES_AT_THE_TOP), + *(recording(home_path(TAKES) / INNER / name, SHORT_SECONDS) for name in TAKES_INSIDE), + *(recording(home_path(LOOPS) / name, SHORT_SECONDS) for name in LOOPS_HELD), + *(recording(home_path(name), SHORT_SECONDS) for name in COLLIDING), + WrittenBytes(home_path(NOTES) / INNER / "readme.txt", NOTE_TEXT), + ] + world = lived_in_world() + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(files), + ) + + +@pytest.fixture +def world() -> World: + """The home holds the recordings and folders the gathering scenarios pick from.""" + return gathering_world() diff --git a/tests/screens/main/gathering/constants.py b/tests/screens/main/gathering/constants.py new file mode 100644 index 000000000..2733d40aa --- /dev/null +++ b/tests/screens/main/gathering/constants.py @@ -0,0 +1,10 @@ +from typing import Final, Tuple + +TAKES: Final[str] = "Takes" +INNER: Final[str] = "Inner" +LOOPS: Final[str] = "Loops" +NOTES: Final[str] = "Notes" +TAKES_AT_THE_TOP: Final[Tuple[str, ...]] = ("one.wav", "two.wav", "three.wav") +TAKES_INSIDE: Final[Tuple[str, ...]] = ("four.wav", "five.wav") +LOOPS_HELD: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +COLLIDING: Final[Tuple[str, ...]] = ("Kick Loop.wav", "kick loop.wav", "żółw.wav") diff --git a/tests/screens/main/gathering/steps.py b/tests/screens/main/gathering/steps.py new file mode 100644 index 000000000..73ff3d689 --- /dev/null +++ b/tests/screens/main/gathering/steps.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from tests.suite.screens.screen import Screen + + +def gathered(screen: Screen, *paths: Path) -> None: + """Waits until the list holds a row for each of ``paths`` and no other row.""" + converter = screen.main.converter + expected = sorted(converter.list.row(path) for path in paths) + screen.expect( + lambda: sorted(converter.list.rows()), + expected.__eq__, + description=f"the list holding {[path.name for path in paths]} alone", + ) diff --git a/tests/screens/main/gathering/test_card_header.py b/tests/screens/main/gathering/test_card_header.py new file mode 100644 index 000000000..4092efa2e --- /dev/null +++ b/tests/screens/main/gathering/test_card_header.py @@ -0,0 +1,37 @@ +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_COLLAPSE_STRIP, + TAG_GLOBAL_THEME_COLLAPSE_HEADER, + TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED, +) +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_SOURCE_PANEL +from tests.suite.screens.screen import Screen + + +class TestACardHeaderUnderThePointer: + """A card's header bar lights while the pointer rests on it and settles back once the pointer leaves. + + The pointer rests on the Converter's header bar, which lights. Then it moves to the Source settings + header, and the first bar returns to its resting theme. + """ + + def test_the_bar_lights_and_settles(self, screen: Screen) -> None: + strip = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_COLLAPSE_STRIP) + elsewhere = compose_tag(TAG_MAIN_SOURCE_PANEL, SUF_COLLAPSE_STRIP) + + def rest_on_the_bar(screen: Screen) -> None: + screen.hand.hover(strip) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED.__eq__, description="lit" + ) + + def move_off(screen: Screen) -> None: + screen.hand.scroll_into_view(elsewhere) + screen.hand.hover(elsewhere) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER.__eq__, description="settled" + ) + + screen.scenario(rest_on_the_bar, move_off).run() diff --git a/tests/screens/main/gathering/test_folders_and_edges.py b/tests/screens/main/gathering/test_folders_and_edges.py new file mode 100644 index 000000000..00f4292aa --- /dev/null +++ b/tests/screens/main/gathering/test_folders_and_edges.py @@ -0,0 +1,145 @@ +import operator +from pathlib import Path +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.gathering.constants import ( + COLLIDING, + LOOPS, + LOOPS_HELD, + NOTES, + TAKES, + TAKES_AT_THE_TOP, + TAKES_INSIDE, +) +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK + +ADD_FOLDER: Final[str] = "main.explorer.label.context_add_folder_stems" +NOTHING_BELOW: Final[str] = "main.converter.message.scan_nothing_below" + + +def folder_label(screen: Screen, path: Path, count: int) -> str: + """Returns the text a folder row shows for ``path`` holding ``count`` recordings.""" + return screen.words(FOLDER_ROW).format(name=path.name, count=count) + + +def wait_for_the_scan_to_end(screen: Screen) -> None: + """Waits until the Converter has finished reading the folders it was given.""" + screen.expect(lambda: not screen.main.converter.scan_shown(), bool, description="the folder read") + + +class TestGatheringAFolder: + """A folder lands as one row counting its recordings, subfolders included, and starts no run. + + A recording is gathered first. Ctrl-click on the Takes folder and Add folder from the menu of the + Loops folder each add one counted row. The scenario ends with no run on screen and the recording + still on top. + """ + + def test_ctrl_click_and_add_folder_each_give_one_counted_row(self, screen: Screen) -> None: + """Each gesture gives its folder one row with the count of the recordings inside.""" + converter = screen.main.converter + + def gather_a_recording_first(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + + def ctrl_click_a_folder(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(TAKES))) + + gathered(screen, home_path(KICK), home_path(TAKES)) + wait_for_the_scan_to_end(screen) + count = len(TAKES_AT_THE_TOP) + len(TAKES_INSIDE) + assert converter.list.label(home_path(TAKES)) == folder_label(screen, home_path(TAKES), count) + + def add_folder_from_its_menu(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(LOOPS))) + screen.expect(screen.context_menu.is_shown, bool, description="the folder's menu") + + screen.context_menu.choose(screen.words(ADD_FOLDER)) + + gathered(screen, home_path(KICK), home_path(TAKES), home_path(LOOPS)) + wait_for_the_scan_to_end(screen) + assert converter.list.label(home_path(LOOPS)) == folder_label(screen, home_path(LOOPS), len(LOOPS_HELD)) + + def no_run_started_and_the_recording_stands_first(screen: Screen) -> None: + assert not converter.run_shown() + assert converter.action() != screen.words(CANCEL_RUN) + assert converter.list.rows()[0] == converter.list.row(home_path(KICK)) + + screen.scenario( + gather_a_recording_first, + ctrl_click_a_folder, + add_folder_from_its_menu, + no_run_started_and_the_recording_stands_first, + ).run() + + +class TestGatheringAtTheEdges: + """Gathering copes with its edges: a folder gathered twice, a folder with no recordings and names that + collide. + """ + + def test_a_folder_gathered_twice_stands_once(self, screen: Screen) -> None: + """Ctrl-clicking the same folder twice leaves one row for it.""" + for _ in range(2): + screen.explorer.ctrl_click(explorer_row(screen, home_path(TAKES))) + wait_for_the_scan_to_end(screen) + + gathered(screen, home_path(TAKES)) + + def test_a_folder_of_no_recordings_says_so_and_gathers_nothing(self, screen: Screen) -> None: + """A folder holding only words shows a notice and adds no row; the next folder of sounds gathers + once the notice is dismissed. + """ + converter = screen.main.converter + notice = converter.nothing_below_notice + + def ctrl_click_a_folder_of_words(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(NOTES))) + + screen.expect(notice.is_shown, bool, description="the notice") + assert notice.words() == screen.words(NOTHING_BELOW) + + def dismiss_and_gather_a_folder_of_sounds(screen: Screen) -> None: + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(LOOPS))) + + gathered(screen, home_path(LOOPS)) + + screen.scenario(ctrl_click_a_folder_of_words, dismiss_and_gather_a_folder_of_sounds).run() + + def test_names_colliding_in_case_and_spacing_stand_apart(self, screen: Screen) -> None: + """Recordings whose names differ only in case or spacing get separate rows, and ticking a box on + one leaves the others as they were. + """ + converter = screen.main.converter + paths = [home_path(name) for name in COLLIDING] + + def gather_each(screen: Screen) -> None: + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + + gathered(screen, *paths) + + def a_box_on_one_leaves_the_others(screen: Screen) -> None: + first, *others = paths + before = [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] + + converter.list.tick(first, ChannelName.PULSE1) + + screen.expect( + lambda: converter.list.channel_ticked(first, ChannelName.PULSE1), + (not before[0]).__eq__, + description="the box flipped on the first", + ) + assert [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] == before + + screen.scenario(gather_each, a_box_on_one_leaves_the_others).run() diff --git a/tests/screens/main/gathering/test_navigating_and_clicking.py b/tests/screens/main/gathering/test_navigating_and_clicking.py new file mode 100644 index 000000000..edc5158c0 --- /dev/null +++ b/tests/screens/main/gathering/test_navigating_and_clicking.py @@ -0,0 +1,81 @@ +from functools import partial +from typing import Final + +from tests.screens.main.gathering.constants import INNER, LOOPS, TAKES +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.converter import CONVERT_NOTHING, CONVERT_ONE +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +ADD_AS_STEM: Final[str] = "main.explorer.label.context_add_stem" + + +class TestNavigatingGathersNothing: + """Opening folders in the explorer leaves the list empty; a double-click on a recording gathers that + one. + + The scenario opens three folders and expects the hint, an empty list and the idle Convert button. A + double-click on a recording then gives the list one row and names that recording on the button. + """ + + def test_the_list_stays_empty_until_a_recording_is_double_clicked(self, screen: Screen) -> None: + """The list holds no row until a recording is double-clicked.""" + converter = screen.main.converter + + def open_folders(screen: Screen) -> None: + for folder in (home_path(TAKES), home_path(TAKES) / INNER, home_path(LOOPS)): + row = explorer_row(screen, folder) + + screen.explorer.open_by_click(row) + + screen.expect(partial(screen.explorer.is_open, row), bool, description=f"{folder.name} open") + + def the_list_stays_empty(screen: Screen) -> None: + assert converter.list.hint_shown() + assert not converter.list.list_shown() + assert converter.action() == screen.words(CONVERT_NOTHING) + + def a_double_click_gathers_that_recording(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(KICK).stem) + assert not converter.list.hint_shown() + + screen.scenario(open_folders, the_list_stays_empty, a_double_click_gathers_that_recording).run() + + +class TestAPlainClickOnARecording: + """A plain click plays a recording; Ctrl-click and Add as stem gather it. + + A click starts playback and the hint stays. Ctrl-click on the same recording gathers it. Add as stem + from another recording's menu gathers that one too, and the menu closes. + """ + + def test_it_plays_and_the_gathering_gestures_gather(self, screen: Screen) -> None: + """A click only plays, while Ctrl-click and the Add as stem entry add rows.""" + converter = screen.main.converter + + def a_click_plays(screen: Screen) -> None: + heard = screen.sound_heard() + screen.explorer.click(explorer_row(screen, home_path(KICK))) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + assert converter.list.hint_shown() + + def ctrl_click_gathers(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + + def add_as_stem_gathers(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(SNARE))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + + screen.context_menu.choose(screen.words(ADD_AS_STEM)) + + gathered(screen, home_path(KICK), home_path(SNARE)) + assert not screen.context_menu.is_shown() + + screen.scenario(a_click_plays, ctrl_click_gathers, add_as_stem_gathers).run() diff --git a/tests/screens/main/gathering/test_right_clicking.py b/tests/screens/main/gathering/test_right_clicking.py new file mode 100644 index 000000000..bbdf5ab5d --- /dev/null +++ b/tests/screens/main/gathering/test_right_clicking.py @@ -0,0 +1,68 @@ +import operator +from typing import Final, List + +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +ROUNDS: Final[int] = 20 +FIRST_COUNTED_ROUND: Final[int] = 2 +POINTER_REACH: Final[int] = 4 + + +class TestRightClickingOverAndOver: + """Twenty right-clicks, alternating between the explorer and the list, each open a menu at the pointer + and leave the interface as large as it was. + + A recording is gathered first. Each round opens the menu, reads its box and entries and dismisses + it. The scenario ends when the item count of the last round equals that of the second round. + """ + + def test_each_menu_stands_at_the_pointer_and_the_interface_does_not_grow(self, screen: Screen) -> None: + """Each menu overlaps the pointer, has labeled entries and the item count stays level.""" + converter = screen.main.converter + menu = screen.context_menu + counts: List[int] = [] + + def gather(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + gathered(screen, home_path(KICK)) + + def right_click_over_and_over(screen: Screen) -> None: + for round_number in range(1, ROUNDS + 1): + in_the_list = round_number % 2 == 0 + row = converter.list.row(home_path(KICK)) if in_the_list else explorer_row(screen, home_path(SNARE)) + if in_the_list: + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + else: + screen.explorer.right_click(row) + screen.expect(menu.is_shown, bool, description=f"the menu of round {round_number}") + pointer = screen.pointer() + + box = menu.box() + entries = menu.entries() + menu.dismiss() + + screen.expect(menu.is_shown, operator.not_, description=f"the menu of round {round_number} put away") + assert ( + box.overlap( + Rect( + x=pointer.x - POINTER_REACH, + y=pointer.y - POINTER_REACH, + width=2 * POINTER_REACH, + height=2 * POINTER_REACH, + ) + ) + is not None + ), (box, pointer) + assert entries and all(entry.enabled or entry.label for entry in entries) + if round_number >= FIRST_COUNTED_ROUND: + counts.append(screen.item_count()) + + def nothing_grew(screen: Screen) -> None: + assert counts[-1] == counts[0], counts + + screen.scenario(gather, right_click_over_and_over, nothing_grew).run() diff --git a/tests/screens/main/library/__init__.py b/tests/screens/main/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/library/steps.py b/tests/screens/main/library/steps.py new file mode 100644 index 000000000..ea48fc0d8 --- /dev/null +++ b/tests/screens/main/library/steps.py @@ -0,0 +1,16 @@ +from sampletones_core.configs import Config +from tests.suite.screens.seeds.libraries import MiniLibrary +from tests.suite.screens.worlds.home import World, screen_filling_state + + +def library_world(config: Config, *, built: bool) -> World: + """A world with Advanced settings on and ``config`` in force; with ``built`` set, the home also holds + the library that ``config`` describes. + """ + state = screen_filling_state().model_copy(update={"advanced_settings": True}) + return World( + state=state, + application_config=None, + config=config, + files=(MiniLibrary(config),) if built else (), + ) diff --git a/tests/screens/main/library/test_folders_and_generators.py b/tests/screens/main/library/test_folders_and_generators.py new file mode 100644 index 000000000..95920d895 --- /dev/null +++ b/tests/screens/main/library/test_folders_and_generators.py @@ -0,0 +1,179 @@ +import operator +from typing import Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.structures.tree.node import GeneratorNode +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.screens.main.library.steps import library_world +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items.reading import find_item +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import archived_library +from tests.suite.screens.steps.instructions import library_path, library_row, open_library_row +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.libraries import LIBRARY_LOADED +from tests.suite.screens.worlds.home import World, one_worker_config + +OTHER_LIBRARIES: Final[str] = "Other libraries" + + +def generator_rows(screen: Screen, row: Item) -> List[Item]: + """The generator rows below the library ``row``, read on the render thread.""" + + def read() -> List[Item]: + found: List[Item] = [] + find_item(row, lambda item: _collect_generator(item, found)) + return found + + return screen.bridge.ask(read) + + +def _collect_generator(item: Item, found: List[Item]) -> bool: + user_data = dpg.get_item_user_data(item) + if isinstance(user_data, tuple) and user_data and isinstance(user_data[0], GeneratorNode): + found.append(item) + + return False + + +class TestAnotherLibraryFolder: + """Advanced settings pointed at another folder list that folder's libraries; pointed back, the first + folder's library returns loaded. + + The first library is opened and reads loaded. The library folder is pointed at a folder holding an + older library, where the first library is gone and the older one is flagged outdated. Pointing back + lists the first library again and clears the older one. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library and a folder of other libraries holding an older one.""" + config = one_worker_config() + world = library_world(config, built=True) + return World( + state=world.state, + application_config=None, + config=config, + files=(*world.files, archived_library(home_path(OTHER_LIBRARIES))), + ) + + def test_each_folder_lists_its_own(self, screen: Screen) -> None: + """Each library folder lists its own libraries and none of the other's.""" + library = screen.instructions.library + main = screen.main + ours = library_path(one_worker_config()) + names: List[str] = [] + + def ours_listed_and_loaded(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + row = library_row(screen, ours) + names.append(library.tree.label(row)) + + open_library_row(screen, row) + + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(names[0]).__eq__, description="loaded") + + def point_at_the_other_folder(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + screen.answer_next_dialog(DialogKind.DIRECTORY, home_path(OTHER_LIBRARIES)) + main.choose_library_directory() + screen.expect( + main.library_directory, str(home_path(OTHER_LIBRARIES)).__eq__, description="the other folder" + ) + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(lambda: library.row(ours.name) is None, bool, description="ours no longer listed") + assert library.tree.library_row(lambda node: node.outdated) is not None + + def point_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + screen.answer_next_dialog(DialogKind.DIRECTORY, LIBRARY_DIRECTORY) + main.choose_library_directory() + screen.expect(main.library_directory, str(LIBRARY_DIRECTORY).__eq__, description="our folder") + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + library_row(screen, ours) + assert library.tree.library_row(lambda node: node.outdated) is None + + screen.scenario(ours_listed_and_loaded, point_at_the_other_folder, point_back).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a library folder pointed away from and back lists its library unloaded", + ) + def test_pointing_back_finds_the_library_loaded(self, screen: Screen) -> None: + """After pointing away and back, the first library's status reads loaded without a second opening.""" + library = screen.instructions.library + main = screen.main + ours = library_path(one_worker_config()) + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + name = library.tree.label(library_row(screen, ours)) + open_library_row(screen, library_row(screen, ours)) + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(name).__eq__, description="loaded") + screen.tabs.bring_to_front(Tab.MAIN) + for folder in (home_path(OTHER_LIBRARIES), LIBRARY_DIRECTORY): + screen.answer_next_dialog(DialogKind.DIRECTORY, folder) + main.choose_library_directory() + screen.expect(main.library_directory, str(folder).__eq__, description=f"the folder {folder.name}") + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + library_row(screen, ours) + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(name).__eq__, description="loaded again") + + +class TestGeneratorsUnderALibrary: + """A library this build reads opens onto its generators; one another version built opens onto none. + + The old library's row asks to rebuild when opened, and Cancel keeps it closed. The current + library's row opens onto generator rows while the old one still lists none. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library and an older library in the library folder.""" + config = one_worker_config() + world = library_world(config, built=True) + return World( + state=world.state, + application_config=None, + config=config, + files=(*world.files, archived_library(LIBRARY_DIRECTORY)), + ) + + def test_the_old_one_lists_none_and_ours_lists_them(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.rebuild_prompt + ours = library_path(one_worker_config()) + + def the_old_one_opens_onto_nothing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + old = screen.expect_item( + lambda: library.tree.library_row(lambda node: node.outdated), + description="the old library's row", + ) + + library.tree.open_by_click(old) + + screen.expect(prompt.is_shown, bool, description="the rebuild question") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + def ours_opens_onto_its_generators(screen: Screen) -> None: + open_library_row(screen, library_row(screen, ours)) + + screen.expect( + lambda: len(generator_rows(screen, library_row(screen, ours))), bool, description="generator rows" + ) + old = library.tree.library_row(lambda node: node.outdated) + assert old is not None + assert generator_rows(screen, old) == [] + + screen.scenario(the_old_one_opens_onto_nothing, ours_opens_onto_its_generators).run() diff --git a/tests/screens/main/library/test_generating.py b/tests/screens/main/library/test_generating.py new file mode 100644 index 000000000..65e7fcdc8 --- /dev/null +++ b/tests/screens/main/library/test_generating.py @@ -0,0 +1,140 @@ +import operator +from functools import partial +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from tests.screens.main.library.steps import library_world +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.instructions import library_path, library_row +from tests.suite.screens.vocabulary.libraries import LIBRARY_LOADED +from tests.suite.screens.worlds.home import World, one_worker_config + +QUICK_SAMPLE_RATE: Final[int] = 11025 +GENERATION_TIMEOUT_SECONDS: Final[float] = 300.0 +LIBRARY_MISSING: Final[str] = "instructions.library.template.library_not_exists_template" +GENERATE: Final[str] = "instructions.library.label.generate_library_button" +REGENERATE: Final[str] = "instructions.library.label.regenerate_library_button" +GENERATION_CANCELED: Final[str] = "instructions.library.message.status_generation_canceled" + + +def quick_config() -> Config: + """Settings whose library generates in seconds: a low sample rate, measured by the plain spectrum.""" + config = one_worker_config() + library = config.library.model_copy( + update={"sample_rate": QUICK_SAMPLE_RATE, "spectrum_method": SpectrumMethod.FFT} + ) + return config.model_copy(update={"library": library}) + + +class TestGeneratingALibrary: + """A configuration with no library generates one, which the tree lists and the status reads as loaded.""" + + @pytest.fixture + def world(self) -> World: + """A home holding no library for the quick settings.""" + return library_world(quick_config(), built=False) + + def test_generate_lists_it_loaded_and_offers_regenerate(self, screen: Screen) -> None: + """Generate lists the library, the status reads it loaded, and the button then offers Regenerate.""" + library = screen.instructions.library + path = library_path(quick_config()) + + def nothing_to_list(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered") + assert library.row(path.name) is None + + def generate(screen: Screen) -> None: + library.generate() + + row = screen.bridge.expect( + partial(library.row, path.name), + lambda found: found is not None, + description="the library listed", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert row is not None + name = library.tree.label(row) + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the library loaded", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert library.generate_label() == screen.words(REGENERATE) + assert path.exists() + + screen.scenario(nothing_to_list, generate).run() + + def test_cancelling_a_generation_leaves_no_library(self, screen: Screen) -> None: + """Canceling a generation reports it; Generate is offered again and the library has no file or row.""" + library = screen.instructions.library + path = library_path(quick_config()) + + def start_and_cancel(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library.generate() + + library.cancel_generation() + + screen.bridge.expect( + library.notice.is_shown, bool, description="the cancel reported", timeout=GENERATION_TIMEOUT_SECONDS + ) + assert library.notice.words() == screen.words(GENERATION_CANCELED) + library.notice.dismiss() + screen.expect(library.notice.is_shown, operator.not_, description="the report dismissed") + + def nothing_was_written(screen: Screen) -> None: + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered again") + + assert not path.exists() + assert library.row(path.name) is None + assert library.status().endswith(screen.words(LIBRARY_MISSING).format("")) + + screen.scenario(start_and_cancel, nothing_was_written).run() + + +class TestRegeneratingALibrary: + """Regenerate asks first, and Cancel keeps the library as it stands. + + The built library is listed and Regenerate pressed. Cancel on the question keeps the file's bytes, + and the next press asks again. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library.""" + return library_world(one_worker_config(), built=True) + + def test_cancel_keeps_it_and_the_next_press_asks_again(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.regenerate_prompt + path = library_path(one_worker_config()) + kept: List[bytes] = [] + + def regenerate_asks(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library_row(screen, path) + kept.append(path.read_bytes()) + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question") + + def cancel_keeps_it(screen: Screen) -> None: + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question asked again") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone again") + assert path.read_bytes() == kept[0] + + screen.scenario(regenerate_asks, cancel_keeps_it).run() diff --git a/tests/screens/main/list/__init__.py b/tests/screens/main/list/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/list/conftest.py b/tests/screens/main/list/conftest.py new file mode 100644 index 000000000..db355f808 --- /dev/null +++ b/tests/screens/main/list/conftest.py @@ -0,0 +1,24 @@ +from typing import Final, List + +import pytest + +from tests.screens.main.list.constants import FORTY, FORTY_COUNT +from tests.screens.main.list.steps import recording, take +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import lived_in_world + +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.02 + + +@pytest.fixture +def world() -> World: + """The home holds the Kick and Snare recordings and a Forty folder of short takes.""" + files: List[HomeFile] = [ + recording(home_path(KICK), PLAYED_SECONDS), + recording(home_path(SNARE), PLAYED_SECONDS), + *(recording(home_path(FORTY) / take(index), SHORT_SECONDS) for index in range(FORTY_COUNT)), + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) diff --git a/tests/screens/main/list/constants.py b/tests/screens/main/list/constants.py new file mode 100644 index 000000000..12d164b6c --- /dev/null +++ b/tests/screens/main/list/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +FORTY: Final[str] = "Forty" +FORTY_COUNT: Final[int] = 40 +FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/main/list/steps.py b/tests/screens/main/list/steps.py new file mode 100644 index 000000000..9fc43535f --- /dev/null +++ b/tests/screens/main/list/steps.py @@ -0,0 +1,27 @@ +from functools import partial +from pathlib import Path + +from tests.screens.main.list.constants import FREQUENCY +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import explorer_row +from tests.suite.screens.worlds.home import HomeFile + + +def take(index: int) -> str: + """Returns the file name of the numbered take, such as ``take07.wav``.""" + return f"take{index:02d}.wav" + + +def recording(path: Path, seconds: float) -> HomeFile: + """Returns a seeded tone recording of ``seconds`` to be written at ``path``.""" + return Recording(destination=path, seconds=seconds, frequency=FREQUENCY) + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer and waits for its row and for the folder read to end.""" + converter = screen.main.converter + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(partial(converter.list.has_row, path), bool, description=f"{path.name} gathered") + screen.expect(lambda: not converter.scan_shown(), bool, description="the read done") diff --git a/tests/screens/main/list/test_folders.py b/tests/screens/main/list/test_folders.py new file mode 100644 index 000000000..73f295439 --- /dev/null +++ b/tests/screens/main/list/test_folders.py @@ -0,0 +1,158 @@ +import operator +from functools import partial +from typing import Final + +import dearpygui.dearpygui as dpg + +from tests.screens.main.list.constants import FORTY, FORTY_COUNT +from tests.screens.main.list.steps import gather, recording, take +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import read_scroll +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import choose_from_the_row_menu, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK + +STILL_FRAMES: Final[int] = 30 +SHOW_RECORDINGS: Final[str] = "main.converter.label.context_open_folder" +HIDE_RECORDINGS: Final[str] = "main.converter.label.context_close_folder" + + +class TestAFolderInTheList: + """A gathered folder arrives closed; its marker, a double-click on its name and its menu each open and + close it. + """ + + def test_each_gesture_opens_and_closes_it(self, screen: Screen) -> None: + """Each gesture opens the folder and the same gesture closes it again.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def arrives_closed(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + + def the_marker(screen: Screen) -> None: + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="open by its marker") + + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by its marker") + + def a_double_click_on_its_name(screen: Screen) -> None: + screen.hand.double_click(listing.row(folder)) + screen.expect(partial(listing.is_open, folder), bool, description="open by a double-click") + + screen.hand.double_click(listing.row(folder)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by a double-click") + + def its_menu(screen: Screen) -> None: + choose_from_the_row_menu(screen, folder, screen.words(SHOW_RECORDINGS)) + screen.expect(partial(listing.is_open, folder), bool, description="open from its menu") + + choose_from_the_row_menu(screen, folder, screen.words(HIDE_RECORDINGS)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed from its menu") + + screen.scenario(arrives_closed, the_marker, a_double_click_on_its_name, its_menu).run() + + +class TestAFolderThatScrolls: + """An open folder of forty scrolls in its own region, end to end, and its rows hold still under the + pointer. + + The folder opens beside a recording. The wheel takes it to the last row while the outer list keeps + its scroll. The pointer then rests on that row for thirty frames and its rectangle stays the same. + """ + + def test_it_scrolls_on_its_own_to_its_last_row(self, screen: Screen) -> None: + """The folder scrolls to its last row in its own region.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + region = listing.tags.region(str(folder)) + last = folder / take(FORTY_COUNT - 1) + + def open_it(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert screen.bridge.ask(lambda: read_scroll(region)).maximum > 0 + + def the_wheel_takes_it_to_its_last_row(screen: Screen) -> None: + well_before = screen.bridge.ask(lambda: read_scroll(listing.tags.well)) + + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + assert screen.bridge.ask(lambda: read_scroll(listing.tags.well)) == well_before + + def rows_hold_still_under_the_pointer(screen: Screen) -> None: + row = listing.row(last) + screen.hand.hover(row) + + with screen.record(lambda: read_item(row).rect) as recording: + screen.frames(STILL_FRAMES) + + assert len(set(recording.values())) == 1, recording.values() + + screen.scenario(open_it, the_wheel_takes_it_to_its_last_row, rows_hold_still_under_the_pointer).run() + + +class TestRemovingFolders: + """Removing a recording inside an open folder, or the folder itself, takes exactly what it names.""" + + def test_a_recording_inside_goes_alone_and_the_folder_counts_one_fewer(self, screen: Screen) -> None: + """Only the chosen recording goes; the folder stays open, counts one fewer and the other rows are + kept. + """ + listing = screen.main.converter.list + folder = home_path(FORTY) + removed = folder / take(1) + + def open_the_folder_beside_a_recording(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + def remove_one_inside(screen: Screen) -> None: + outside = screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home_path(KICK)))) + + listing.remove(removed) + + screen.expect(partial(listing.has_row, removed), operator.not_, description="the recording gone") + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=FORTY_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="the folder counting one fewer") + assert listing.has_row(folder / take(0)) and listing.has_row(folder / take(2)) + assert listing.is_open(folder) + assert screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home_path(KICK)))) == outside + + screen.scenario(open_the_folder_beside_a_recording, remove_one_inside).run() + + def test_an_open_folder_goes_with_its_recordings_and_comes_back_closed(self, screen: Screen) -> None: + """The folder leaves with its recordings, and gathering it again brings it back closed.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def remove_it_open(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.remove(folder) + + screen.expect(partial(listing.has_row, folder), operator.not_, description="the folder gone") + assert listing.rows() == [listing.row(home_path(KICK))] + + def gather_it_again(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(folder)] + + screen.scenario(remove_it_open, gather_it_again).run() diff --git a/tests/screens/main/list/test_look.py b/tests/screens/main/list/test_look.py new file mode 100644 index 000000000..513036eae --- /dev/null +++ b/tests/screens/main/list/test_look.py @@ -0,0 +1,75 @@ +from functools import partial +from typing import Dict, Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_TEXT, TAG_GLOBAL_THEME_STEMS_GROUP_ROW +from sampletones_application.tags.main import PRE_MAIN_CONVERTER_STEMS +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.list.constants import FORTY +from tests.screens.main.list.steps import gather +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK + +SAME_LINE_PIXELS: Final[float] = 1.0 +HEADING: Final[str] = "heading" + +CHANNEL_NAMES: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: "global.context.label.pulse_1", + ChannelName.PULSE2: "global.context.label.pulse_2", + ChannelName.TRIANGLE: "global.context.label.triangle", + ChannelName.NOISE: "global.context.label.noise", +} + + +class TestTheListsLook: + """The channel names above the list wear their channel's color; a folder row carries the band and has a + recording's height. + + A recording and a folder are gathered. The scenario reads the heading texts and themes, the band + theme of each row, the row heights and the line shared by a recording's name and its boxes. + """ + + def test_names_bands_and_lines(self, screen: Screen) -> None: + """Names, bands, heights and lines match what the list should look like.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def gather_a_recording_and_a_folder(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + + def the_channel_names_wear_their_colours(screen: Screen) -> None: + for channel, key in CHANNEL_NAMES.items(): + name = compose_tag(PRE_MAIN_CONVERTER_STEMS, HEADING, channel, SUF_TEXT) + + assert screen.bridge.ask(partial(dpg.get_value, name)) == screen.words(key) + assert screen.theme_of(name) == CHANNEL_THEME_TAGS[channel] + + def the_folder_alone_carries_the_band(screen: Screen) -> None: + assert listing.band_theme(folder) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW + assert listing.band_theme(home_path(KICK)) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def rows_share_a_height_and_names_share_a_line_with_their_boxes(screen: Screen) -> None: + folder_box = screen.bridge.ask(lambda: read_item(listing.row(folder)).rect) + recording_box = screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK))).rect) + check_box = screen.bridge.ask( + lambda: read_item(listing.channel_box(home_path(KICK), ChannelName.PULSE1)).rect + ) + assert folder_box is not None and recording_box is not None and check_box is not None + + assert folder_box.height == recording_box.height + assert ( + abs((recording_box.y + recording_box.height / 2) - (check_box.y + check_box.height / 2)) + <= SAME_LINE_PIXELS + ) + + screen.scenario( + gather_a_recording_and_a_folder, + the_channel_names_wear_their_colours, + the_folder_alone_carries_the_band, + rows_share_a_height_and_names_share_a_line_with_their_boxes, + ).run() diff --git a/tests/screens/main/list/test_rows.py b/tests/screens/main/list/test_rows.py new file mode 100644 index 000000000..d9ca1c0f4 --- /dev/null +++ b/tests/screens/main/list/test_rows.py @@ -0,0 +1,132 @@ +import operator +from functools import partial +from typing import Final + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.list.steps import gather +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.converter import CONVERT_ONE, CONVERT_SEVERAL +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +PLAY: Final[str] = "global.context.label.play" + + +class TestTheListComingAndGoing: + """With nothing gathered the hint shows alone; the first recording brings the list, and removing the + last one brings the hint back. + """ + + def test_the_hint_and_the_list_take_turns(self, screen: Screen) -> None: + """The hint and the list swap places as recordings arrive and leave.""" + listing = screen.main.converter.list + + def the_hint_alone(screen: Screen) -> None: + assert listing.hint_shown() + assert not listing.list_shown() + + def the_first_recording_brings_the_list(screen: Screen) -> None: + gather(screen, home_path(KICK)) + + assert listing.list_shown() + assert not listing.hint_shown() + + def removing_the_last_takes_it_away(screen: Screen) -> None: + listing.remove(home_path(KICK)) + + screen.expect(listing.hint_shown, bool, description="the hint back") + assert not listing.list_shown() + assert listing.rows() == [] + + screen.scenario(the_hint_alone, the_first_recording_brings_the_list, removing_the_last_takes_it_away).run() + + +class TestUntickingEveryChannel: + """A recording with every channel unticked reads as out of play and leaves the run count, and stays + listed. + """ + + def test_the_row_dims_and_the_run_counts_without_it(self, screen: Screen) -> None: + """The row takes the inert theme and the button counts one recording; ticking one channel restores + both. + """ + listing = screen.main.converter.list + converter = screen.main.converter + + def gather_two(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def untick_every_channel_of_one(screen: Screen) -> None: + for channel in ChannelName: + if listing.channel_ticked(home_path(KICK), channel): + listing.tick(home_path(KICK), channel) + screen.expect( + partial(listing.channel_ticked, home_path(KICK), channel), + operator.not_, + description=f"{channel} let go", + ) + + screen.expect( + partial(listing.row_theme, home_path(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW_INERT.__eq__, + description="the row out of play", + ) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(SNARE).stem) + assert listing.has_row(home_path(KICK)) + + def one_channel_brings_it_back(screen: Screen) -> None: + listing.tick(home_path(KICK), ChannelName.PULSE1) + + screen.expect( + partial(listing.row_theme, home_path(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW.__eq__, + description="the row in play", + ) + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + screen.scenario(gather_two, untick_every_channel_of_one, one_channel_brings_it_back).run() + + +class TestPlayingFromTheList: + """A recording in the list plays on a double-click, and its menu leads with Play while the file exists.""" + + def test_a_double_click_plays_and_play_greys_once_the_file_is_gone(self, screen: Screen) -> None: + """Play is the first menu entry and turns disabled after the file is deleted from the disk.""" + listing = screen.main.converter.list + menu = screen.context_menu + + def a_double_click_plays(screen: Screen) -> None: + gather(screen, home_path(KICK)) + heard = screen.sound_heard() + + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + assert listing.rows() == [listing.row(home_path(KICK))] + + def its_menu_leads_with_play(screen: Screen) -> None: + screen.hand.right_click(listing.row(home_path(KICK))) + screen.expect(menu.is_shown, bool, description="the menu") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), True) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def play_greys_once_the_file_is_gone(screen: Screen) -> None: + home_path(KICK).unlink() + + screen.hand.right_click(listing.row(home_path(KICK))) + screen.expect(menu.is_shown, bool, description="the menu again") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), False) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away again") + + screen.scenario(a_double_click_plays, its_menu_leads_with_play, play_greys_once_the_file_is_gone).run() diff --git a/tests/screens/main/list/test_thousands.py b/tests/screens/main/list/test_thousands.py new file mode 100644 index 000000000..22d0ef577 --- /dev/null +++ b/tests/screens/main/list/test_thousands.py @@ -0,0 +1,124 @@ +from functools import partial +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.paths import LAYOUT_DIRECTORY +from sampletones_shared.utils.serialization import load_yaml_model +from tests.screens.main.list.constants import FREQUENCY +from tests.screens.main.list.steps import gather +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW, REMOVE_RECORDING +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +THOUSANDS: Final[str] = "Thousands" +THOUSANDS_COUNT: Final[int] = 1500 +TINY_SECONDS: Final[float] = 0.01 +ONE_ROW: Final[int] = 1 +STEMS_LAYOUT: Final[Path] = LAYOUT_DIRECTORY / "general" / "stems.yaml" +MENU_ROW: Final[int] = 3 +WHEEL_PAST: Final[int] = 5 + + +def thousand(index: int) -> str: + """Returns the file name of the numbered recording in the folder of thousands.""" + return f"take{index:04d}.wav" + + +def rows_inside(screen: Screen, folder: Path) -> List[str]: + """Returns the rows of ``folder`` that the list draws at this moment, in list order.""" + listing = screen.main.converter.list + inside = {listing.row(folder / thousand(index)) for index in range(THOUSANDS_COUNT)} + return [row for row in listing.rows() if row in inside] + + +class TestAFolderOfThousands: + """A folder of thousands opens drawing only the rows in view; its last row and any row menu still + answer. + + The folder is gathered and opened. One scenario scrolls to the last row. The other opens a row's + menu, turns the wheel, chooses Remove and expects that row alone to be gone. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds one folder of 1500 tiny recordings.""" + files = [ + Recording(destination=home_path(THOUSANDS) / thousand(index), seconds=TINY_SECONDS, frequency=FREQUENCY) + for index in range(THOUSANDS_COUNT) + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) + + def open_it(self, screen: Screen) -> None: + """Gathers the folder of thousands and opens it in the list.""" + listing = screen.main.converter.list + gather(screen, home_path(THOUSANDS)) + listing.toggle_folder(home_path(THOUSANDS)) + screen.expect(partial(listing.is_open, home_path(THOUSANDS)), bool, description="the folder open") + + def test_it_draws_the_rows_in_view_and_reaches_its_last(self, screen: Screen) -> None: + """The drawn rows fill the region plus the overscan, and scrolling to the end draws the last row.""" + listing = screen.main.converter.list + folder = home_path(THOUSANDS) + region = listing.tags.region(str(folder)) + last = folder / thousand(THOUSANDS_COUNT - 1) + + def opens_drawing_what_is_in_view(screen: Screen) -> None: + self.open_it(screen) + drawn = rows_inside(screen, folder) + region_box = screen.bridge.ask(lambda: read_item(region).rect) + first = screen.bridge.ask(lambda: read_item(drawn[0]).rect) + second = screen.bridge.ask(lambda: read_item(drawn[1]).rect) + assert region_box is not None and first is not None and second is not None + pitch = second.y - first.y + overscan = load_yaml_model(STEMS_LAYOUT, StemsListLayout).window_overscan + + assert len(drawn) <= int(region_box.height / pitch) + ONE_ROW + 2 * overscan + + def reaches_the_last_row(screen: Screen) -> None: + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(opens_drawing_what_is_in_view, reaches_the_last_row).run() + + def test_remove_chosen_after_the_wheel_turned_removes_the_row_the_menu_was_opened_on(self, screen: Screen) -> None: + """The menu stays open while the wheel turns, and Remove deletes the row it was opened on.""" + listing = screen.main.converter.list + menu = screen.context_menu + folder = home_path(THOUSANDS) + region = listing.tags.region(str(folder)) + target = folder / thousand(MENU_ROW) + + def open_the_menu_and_turn_the_wheel(screen: Screen) -> None: + self.open_it(screen) + screen.hand.scroll_into_view(listing.row(target)) + screen.hand.right_click(listing.row(target)) + screen.expect(menu.is_shown, bool, description="the row's menu") + + screen.hand.turn_wheel_over(region, WHEEL_PAST) + + assert menu.is_shown() + + def remove_from_the_menu(screen: Screen) -> None: + menu.choose(screen.words(REMOVE_RECORDING)) + + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=THOUSANDS_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="one row fewer") + + def that_row_alone_went(screen: Screen) -> None: + screen.hand.turn_wheel_over(region, -2 * WHEEL_PAST) + screen.expect(partial(listing.has_row, folder / thousand(0)), bool, description="the top in view") + + assert not listing.has_row(target) + assert listing.has_row(folder / thousand(MENU_ROW - 1)) and listing.has_row(folder / thousand(MENU_ROW + 1)) + + screen.scenario(open_the_menu_and_turn_the_wheel, remove_from_the_menu, that_row_alone_went).run() diff --git a/tests/screens/main/mix/__init__.py b/tests/screens/main/mix/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/mix/test_turning_to_a_mix.py b/tests/screens/main/mix/test_turning_to_a_mix.py new file mode 100644 index 000000000..e3ad3d198 --- /dev/null +++ b/tests/screens/main/mix/test_turning_to_a_mix.py @@ -0,0 +1,224 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import REMOVE_RECORDING +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +GATHERED: Final[int] = MAX_STEM_SOURCES + 2 +SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 +NAMES_CLICKED: Final[int] = 4 +COUNT_LINE: Final[str] = "main.converter.template.stem_selection_limit" + +MOVES: Final[List[str]] = [ + "main.converter.label.context_move_up", + "main.converter.label.context_move_down", + "main.converter.label.context_join_above", + "main.converter.label.context_join_below", + "main.converter.label.context_isolate", +] + + +def recording(index: int) -> Path: + """The home path of the gathered recording numbered ``index``.""" + return home_path(f"voice{index:02d}.wav") + + +def recordings(count: int) -> List[Path]: + """The home paths of the first ``count`` gathered recordings.""" + return [recording(index) for index in range(count)] + + +@pytest.fixture +def world() -> World: + """A lived-in home holding two more recordings than a mix has room for.""" + files = tuple(Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) for path in recordings(GATHERED)) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + +def count_line(screen: Screen, picked: int, total: int) -> str: + """The line the mix question shows for ``picked`` recordings ticked out of ``total``.""" + return screen.words(COUNT_LINE).format(picked=picked, total=total, room=MAX_STEM_SOURCES) + + +class TestTurningToAMixPastItsRoom: + """Turning to a mix with more recordings gathered than a mix holds asks which to take, with a full + pick ticked. + """ + + def test_the_question_ticks_a_full_pick_and_cancel_leaves_everything(self, screen: Screen) -> None: + """The question ticks the first recordings up to the room of a mix; Cancel keeps the list and the + per-recording switch, and turning to a mix again asks again. + """ + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + + def turn_to_a_mix(screen: Screen) -> None: + gather(screen, *paths) + + main.choose_output(OutputKind.MIXED) + + screen.expect(question.is_shown, bool, description="the question") + picked, past = paths[:MAX_STEM_SOURCES], paths[MAX_STEM_SOURCES:] + assert all(question.is_ticked(path) for path in picked) + assert not any(question.is_ticked(path) or question.is_live(path) for path in past) + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES, GATHERED) + + def cancel_leaves_the_list_and_the_switch(screen: Screen) -> None: + question.cancel() + + screen.expect(question.is_shown, operator.not_, description="the question gone") + assert main.output() is OutputKind.PER_RECORDING + assert main.converter.list.rows() == [main.converter.list.row(path) for path in paths] + + def asking_again_asks_again(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(question.is_shown, bool, description="the question again") + question.cancel() + screen.expect(question.is_shown, operator.not_, description="the question gone again") + + screen.scenario(turn_to_a_mix, cancel_leaves_the_list_and_the_switch, asking_again_asks_again).run() + + def test_a_full_pick_swaps_one_for_one_and_add_waits_for_a_pick(self, screen: Screen) -> None: + """With a full pick, letting one go makes the next recording live for ticking; Add answers once at + least one recording is picked and then makes the mix. + """ + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + ninth = paths[MAX_STEM_SOURCES] + + def open_the_question(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + + def letting_one_go_makes_the_ninth_live(screen: Screen) -> None: + assert not question.is_live(ninth) + + question.tick(paths[0]) + + screen.expect(partial(question.is_live, ninth), bool, description="the ninth live") + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES - 1, GATHERED) + question.tick(ninth) + screen.expect(partial(question.is_ticked, ninth), bool, description="the ninth picked") + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES, GATHERED) + assert not question.is_live(paths[0]) + + def add_waits_until_something_is_picked(screen: Screen) -> None: + for path in paths[1 : MAX_STEM_SOURCES + 1]: + question.tick(path) + screen.expect(question.can_add, operator.not_, description="Add waiting") + assert question.count_line() == count_line(screen, 0, GATHERED) + + question.tick(paths[0]) + + screen.expect(question.can_add, bool, description="Add answering") + + def add_makes_the_mix(screen: Screen) -> None: + question.add() + + screen.expect(question.is_shown, operator.not_, description="the question gone") + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the switch on a mix") + + screen.scenario( + open_the_question, + letting_one_go_makes_the_ninth_live, + add_waits_until_something_is_picked, + add_makes_the_mix, + ).run() + + def test_a_double_click_sounds_a_row_and_names_leave_no_highlight(self, screen: Screen) -> None: + """A double-click on a name plays that recording, and clicking names leaves the ticks and highlights + as they were. + """ + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + + def open_the_question(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + + def a_double_click_sounds_it(screen: Screen) -> None: + heard = screen.sound_heard() + screen.hand.double_click(question.name(paths[0])) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + + def names_clicked_leave_no_highlight(screen: Screen) -> None: + before = [question.is_ticked(path) for path in paths] + for path in paths[1 : 1 + NAMES_CLICKED]: + screen.hand.scroll_into_view(question.name(path)) + screen.hand.click(question.name(path)) + + assert not any(question.is_highlighted(path) for path in paths) + assert [question.is_ticked(path) for path in paths] == before + question.cancel() + screen.expect(question.is_shown, operator.not_, description="the question gone") + + screen.scenario(open_the_question, a_double_click_sounds_it, names_clicked_leave_no_highlight).run() + + +class TestAMixOfOneAndOfTwo: + """A mix of one draws no level band and offers no moves; a second recording brings the bands, and + one per recording takes them away. + + One recording is gathered and the switch turned to a mix. Its right-click menu lists Remove and no + moves. A second recording brings the level bands; turning back to one per recording removes them. + """ + + def test_bands_come_with_the_second_recording_and_go_with_one_per_recording(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + first, second = recordings(2) + + def a_mix_of_one(screen: Screen) -> None: + gather(screen, first) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the switch on a mix") + + assert not listing.has_levels() + + def its_menu_offers_removal_and_no_moves(screen: Screen) -> None: + screen.hand.right_click(listing.row(first)) + screen.expect(menu.is_shown, bool, description="the menu") + + labels = menu.labels() + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert screen.words(REMOVE_RECORDING) in labels + assert not set(labels) & {screen.words(move) for move in MOVES} + + def the_second_brings_the_bands(screen: Screen) -> None: + gather(screen, second) + + screen.expect(listing.has_levels, bool, description="the level bands") + + def one_per_recording_takes_them_away(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + + screen.expect(listing.has_levels, operator.not_, description="the bands gone") + assert listing.rows() == [listing.row(first), listing.row(second)] + + screen.scenario( + a_mix_of_one, + its_menu_offers_removal_and_no_moves, + the_second_brings_the_bands, + one_per_recording_takes_them_away, + ).run() diff --git a/tests/screens/main/row_settings/__init__.py b/tests/screens/main/row_settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/row_settings/conftest.py b/tests/screens/main/row_settings/conftest.py new file mode 100644 index 000000000..8f1ebe39a --- /dev/null +++ b/tests/screens/main/row_settings/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.screens.main.row_settings.steps import recordings +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + + +@pytest.fixture +def world() -> World: + """A lived-in home holding the kick and snare recordings and a folder of two takes.""" + return World(state=lived_in_world().state, application_config=None, config=None, files=recordings()) diff --git a/tests/screens/main/row_settings/constants.py b/tests/screens/main/row_settings/constants.py new file mode 100644 index 000000000..ea4422b59 --- /dev/null +++ b/tests/screens/main/row_settings/constants.py @@ -0,0 +1,6 @@ +from typing import Final, Tuple + +PAIR: Final[str] = "Pair" +PAIR_TAKES: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +PLAYED_SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/main/row_settings/steps.py b/tests/screens/main/row_settings/steps.py new file mode 100644 index 000000000..cf548c9fa --- /dev/null +++ b/tests/screens/main/row_settings/steps.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import Dict, Tuple + +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import FREQUENCY, PAIR, PAIR_TAKES, PLAYED_SECONDS +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import HomeFile + + +def recordings() -> Tuple[HomeFile, ...]: + """The recordings the home holds: kick, snare and the two takes in the Pair folder.""" + return ( + Recording(destination=home_path(KICK), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + Recording(destination=home_path(SNARE), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + *( + Recording(destination=home_path(PAIR) / name, seconds=PLAYED_SECONDS, frequency=FREQUENCY) + for name in PAIR_TAKES + ), + ) + + +def ticked(screen: Screen, path: Path) -> Dict[ChannelName, bool]: + """Reads which channel boxes the list shows ticked on the row at ``path``.""" + return {channel: screen.main.converter.list.channel_ticked(path, channel) for channel in ChannelName} diff --git a/tests/screens/main/row_settings/test_boxes.py b/tests/screens/main/row_settings/test_boxes.py new file mode 100644 index 000000000..7cbfc9b97 --- /dev/null +++ b/tests/screens/main/row_settings/test_boxes.py @@ -0,0 +1,93 @@ +import operator +from functools import partial + +import pytest + +from sampletones_application.ui.themes.channels import PARTIAL_CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import PAIR, PAIR_TAKES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW + + +class TestAFoldersBoxes: + """A folder's box reads partial where its recordings disagree; one click on it settles them all and the + next click lets them go. + + The folder of two takes is gathered and opened. The scenario clicks Pulse 1 on one take, then on the + folder, then on the folder again, and expects the folder's box to turn partial, then every take to + hold the box, then every take to let it go. + """ + + def test_a_box_inside_turns_the_folders_partial_and_the_folders_settles_them(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first, second = (folder / name for name in PAIR_TAKES) + channel = ChannelName.PULSE1 + + def open_the_folder(screen: Screen) -> None: + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def a_box_inside_changes_that_row_alone(screen: Screen) -> None: + listing.tick(first, channel) + + screen.expect(partial(listing.channel_ticked, first, channel), operator.not_, description="the box let go") + assert listing.channel_ticked(second, channel) + screen.expect( + partial(listing.channel_theme, folder, channel), + PARTIAL_CHANNEL_THEME_TAGS[channel].__eq__, + description="the folder's box partial", + ) + assert not listing.channel_ticked(folder, channel) + + def the_folders_box_settles_them_all(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), bool, description="the folder's box held") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def the_next_click_lets_them_go(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), operator.not_, description="let go") + assert not listing.channel_ticked(first, channel) and not listing.channel_ticked(second, channel) + + screen.scenario( + open_the_folder, + a_box_inside_changes_that_row_alone, + the_folders_box_settles_them_all, + the_next_click_lets_them_go, + ).run() + + +class TestTheCardFollowsABoxClicked: + """Ticking a box inside an open folder brings the card to that row, and the folder's own box brings it + to the folder. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a box clicked in the list leaves Source settings where it stood", + ) + def test_the_card_names_the_row_whose_box_was_clicked(self, screen: Screen) -> None: + """Source settings names the take whose box was clicked, then the folder once its box is clicked.""" + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first = folder / PAIR_TAKES[0] + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.tick(first, ChannelName.PULSE1) + + screen.expect(main.source.subject, first.stem.__eq__, description="the card on the row clicked") + listing.tick(folder, ChannelName.PULSE1) + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the card on the folder") diff --git a/tests/screens/main/row_settings/test_during_a_run.py b/tests/screens/main/row_settings/test_during_a_run.py new file mode 100644 index 000000000..4e2989711 --- /dev/null +++ b/tests/screens/main/row_settings/test_during_a_run.py @@ -0,0 +1,93 @@ +import operator +from functools import partial +from typing import Dict, Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.steps import recordings, ticked +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import choose_from_the_row_menu, gather, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, REMOVE_RECORDING +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import converting_world + +HELD_TIMEOUT_SECONDS: Final[float] = 60.0 +PROGRESS: Final[str] = "main.converter.template.progress_template" + + +class TestTheListDuringARun: + """While a run converts the list, Del, the menu's Remove and the channel keys leave the list as it is; + after the run they reach the rows again. + + A kick and a snare recording are gathered and a held run starts. Del and the Pulse 2 key then act on + the picked kick, whose row is disabled and whose boxes stay as they were. Once the run ends and its + question is closed, the menu's Remove takes the snare away and Del takes the kick away. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding the recordings, set up to convert them.""" + return converting_world(recordings()) + + def test_the_list_stands_as_it_was_through_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """The rows and their boxes stay the same through the run, and removal works again after it.""" + converter = screen.main.converter + listing = converter.list + before: List[Dict[ChannelName, bool]] = [] + + def start_a_held_run(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + + converter.press_action() + + started = screen.words(PROGRESS).format(0, 2) + screen.bridge.expect( + converter.status, + lambda status: status.startswith(started), + description="the run under way on its first recording", + timeout=HELD_TIMEOUT_SECONDS, + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def the_keys_and_the_menu_reach_nothing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + assert not screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK)))).enabled + assert not screen.context_menu.is_shown() + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(home_path(SNARE))] + assert ticked(screen, home_path(KICK)) == before[0] + + def after_the_run_the_menu_and_del_reach_the_row(screen: Screen) -> None: + conversion_hold.release() + screen.bridge.expect( + converter.end_prompt.is_shown, + bool, + description="the run's end", + timeout=HELD_TIMEOUT_SECONDS, + ) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end prompt closed") + assert ticked(screen, home_path(KICK)) == before[0] + + choose_from_the_row_menu(screen, home_path(SNARE), screen.words(REMOVE_RECORDING)) + screen.expect( + partial(listing.has_row, home_path(SNARE)), operator.not_, description="removed from the menu" + ) + listing.pick(home_path(KICK)) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(KICK)), operator.not_, description="removed by Del") + + screen.scenario( + start_a_held_run, + the_keys_and_the_menu_reach_nothing, + after_the_run_the_menu_and_del_reach_the_row, + ).run() diff --git a/tests/screens/main/row_settings/test_hundreds.py b/tests/screens/main/row_settings/test_hundreds.py new file mode 100644 index 000000000..2453fbb61 --- /dev/null +++ b/tests/screens/main/row_settings/test_hundreds.py @@ -0,0 +1,72 @@ +import operator +from functools import partial +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from tests.screens.main.row_settings.constants import FREQUENCY +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +HUNDREDS: Final[str] = "Hundreds" +HUNDREDS_COUNT: Final[int] = 300 +TINY_SECONDS: Final[float] = 0.01 + + +class TestHundredsGathered: + """Some three hundred recordings gathered: the card collapses and expands, the interface answers + meanwhile, and the list scrolls end to end. + + The folder is gathered and the card collapsed. The Sequencer and the Main tab are brought to the + front in turn. The card is then expanded, the folder opened and the list scrolled to its end, where + the last row is drawn and hovered. + """ + + @pytest.fixture + def world(self) -> World: + """A lived-in home holding a folder of three hundred very short recordings.""" + files = tuple( + Recording( + destination=home_path(HUNDREDS) / f"take{index:03d}.wav", seconds=TINY_SECONDS, frequency=FREQUENCY + ) + for index in range(HUNDREDS_COUNT) + ) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + def test_collapse_expand_and_scroll_end_to_end(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + folder = home_path(HUNDREDS) + last = folder / f"take{HUNDREDS_COUNT - 1:03d}.wav" + + def gather_and_collapse(screen: Screen) -> None: + gather(screen, folder) + card.toggle() + + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + def the_interface_answers_meanwhile(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + + def expand_and_scroll_end_to_end(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + screen.hand.scroll_to_end(listing.tags.region(str(folder))) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(gather_and_collapse, the_interface_answers_meanwhile, expand_and_scroll_end_to_end).run() diff --git a/tests/screens/main/row_settings/test_keys.py b/tests/screens/main/row_settings/test_keys.py new file mode 100644 index 000000000..76a9280df --- /dev/null +++ b/tests/screens/main/row_settings/test_keys.py @@ -0,0 +1,106 @@ +import operator +from functools import partial +from typing import Dict, Final, List + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_INPUT_SEARCH +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.steps import ticked +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +FILTER_TEXT: Final[str] = "zz" + + +class TestAChannelKey: + """A channel's key settles that channel on the picked row alone; with nothing picked it changes nothing. + + Two recordings are gathered and Pulse 2 is pressed with nothing picked. The kick is then picked and + the key pressed again; the kick's Pulse 2 flips once and the snare keeps its boxes. + """ + + def test_it_reaches_the_picked_row_alone(self, screen: Screen) -> None: + listing = screen.main.converter.list + before: List[Dict[ChannelName, bool]] = [] + + def press_with_nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + before.extend((ticked(screen, home_path(KICK)), ticked(screen, home_path(SNARE)))) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + def press_with_a_row_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped once") + assert ticked(screen, home_path(SNARE)) == before[1] + + screen.scenario(press_with_nothing_picked, press_with_a_row_picked).run() + + +class TestTheKeysOfTheList: + """Del and the channel keys reach the picked row while the Converter card is open and no field holds + the keys. + """ + + def test_a_collapsed_card_keeps_del_from_the_list(self, screen: Screen) -> None: + """Del leaves both rows in place while the card is collapsed and removes the picked row once open.""" + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + + def del_with_the_card_collapsed(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + card.toggle() + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def del_with_the_card_open(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.pick(home_path(SNARE)) + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(del_with_the_card_collapsed, del_with_the_card_open).run() + + def test_a_focused_field_takes_del_and_the_channel_keys(self, screen: Screen) -> None: + """Keys typed into the explorer's filter field stay in the field; once the field lets go, the same key + reaches the picked row. + """ + main = screen.main + listing = main.converter.list + field = compose_tag(TAG_MAIN_EXPLORER_PANEL, SUF_INPUT_SEARCH) + before: List[Dict[ChannelName, bool]] = [] + + def keys_into_the_field(screen: Screen) -> None: + gather(screen, home_path(KICK)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + screen.hand.click(field) + screen.hand.type_text(FILTER_TEXT) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def the_same_key_reaches_the_row_once_the_field_lets_go(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(keys_into_the_field, the_same_key_reaches_the_row_once_the_field_lets_go).run() diff --git a/tests/screens/main/row_settings/test_pick.py b/tests/screens/main/row_settings/test_pick.py new file mode 100644 index 000000000..77c913f55 --- /dev/null +++ b/tests/screens/main/row_settings/test_pick.py @@ -0,0 +1,118 @@ +import operator +from functools import partial +from typing import Final + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import PAIR, PAIR_TAKES +from tests.screens.main.row_settings.steps import ticked +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +NEW_RECORDINGS: Final[str] = "main.source.label.new_recordings" + + +class TestTheCardFollowsThePick: + """Source settings names the picked row, recording or folder, and reads New recordings while no row is + picked. + """ + + def test_it_names_each_pick_in_turn(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + + def nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(PAIR)) + + assert main.source.subject() == screen.words(NEW_RECORDINGS) + + def a_recording_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.expect(main.source.subject, home_path(KICK).stem.__eq__, description="the recording named") + assert listing.is_picked(home_path(KICK)) + assert {channel: main.source.channel_ticked(channel) for channel in ChannelName} == ticked( + screen, home_path(KICK) + ) + + def a_folder_picked(screen: Screen) -> None: + listing.pick(home_path(PAIR)) + + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the folder named") + assert not listing.is_picked(home_path(KICK)) + + screen.scenario(nothing_picked, a_recording_picked, a_folder_picked).run() + + +class TestThePickHoldsThrough: + """The pick stands through a second click, a double-click that plays the row, and Esc stopping it. + + The kick is picked twice, then double-clicked so that it plays, then Esc stops playback. After each + gesture the kick is still the picked row and the card still names it. + """ + + def test_clicks_double_clicks_and_esc_keep_the_pick(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + playback = screen.sequencer.playback + + def pick_twice(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + screen.expect(partial(listing.is_picked, home_path(KICK)), bool, description="picked") + + listing.pick(home_path(KICK)) + + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + def a_double_click_plays_and_keeps_it(screen: Screen) -> None: + heard = screen.sound_heard() + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + assert listing.is_picked(home_path(KICK)) + + def esc_stops_and_keeps_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.can_stop, operator.not_, description="playback stopped") + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + screen.scenario(pick_twice, a_double_click_plays_and_keeps_it, esc_stops_and_keeps_it).run() + + +class TestARightClickMovesThePick: + """A right-click on a row picks it, and Del then removes that row. + + The kick is picked and the snare right-clicked. The card names the snare and the menu is put away; + Del then removes the snare and leaves the kick. + """ + + def test_del_after_a_right_click_removes_the_row_clicked(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + + def right_click_another_row(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + + screen.hand.right_click(listing.row(home_path(SNARE))) + + screen.expect(menu.is_shown, bool, description="the menu") + screen.expect(main.source.subject, home_path(SNARE).stem.__eq__, description="the card on the row clicked") + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def del_removes_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(right_click_another_row, del_removes_it).run() diff --git a/tests/screens/main/run/__init__.py b/tests/screens/main/run/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/run/conftest.py b/tests/screens/main/run/conftest.py new file mode 100644 index 000000000..e5e792563 --- /dev/null +++ b/tests/screens/main/run/conftest.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Final + +import pytest + +from tests.screens.main.run.constants import ALBUM, ALBUM_TAKES, BASS, DISC, DISC_TAKES, DRUMS, LEAD +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import converting_world + +SECONDS: Final[float] = 0.3 +FREQUENCY: Final[float] = 220.0 + + +def recording(path: Path) -> HomeFile: + """A short recording to be written at ``path``.""" + return Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) + + +def run_world() -> World: + """A home holding three recordings and an Album folder with a nested Disc2 folder, set up to convert.""" + paths = [ + home_path(BASS), + home_path(LEAD), + home_path(DRUMS), + *(home_path(ALBUM) / name for name in ALBUM_TAKES), + *(home_path(ALBUM) / DISC / name for name in DISC_TAKES), + ] + return converting_world(tuple(recording(path) for path in paths)) + + +@pytest.fixture +def world() -> World: + """The world of ``run_world``.""" + return run_world() diff --git a/tests/screens/main/run/constants.py b/tests/screens/main/run/constants.py new file mode 100644 index 000000000..a550b5c33 --- /dev/null +++ b/tests/screens/main/run/constants.py @@ -0,0 +1,11 @@ +from typing import Final, Tuple + +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DRUMS: Final[str] = "drums.wav" +ALBUM: Final[str] = "Album" +DISC: Final[str] = "Disc2" +ALBUM_TAKES: Final[Tuple[str, ...]] = ("one.wav", "two.wav") +DISC_TAKES: Final[Tuple[str, ...]] = ("three.wav",) +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" diff --git a/tests/screens/main/run/steps.py b/tests/screens/main/run/steps.py new file mode 100644 index 000000000..983f014cf --- /dev/null +++ b/tests/screens/main/run/steps.py @@ -0,0 +1,31 @@ +import operator +from pathlib import Path +from typing import Dict, List + +from tests.screens.main.run.constants import RECONSTRUCTION_SUFFIX, RUN_TIMEOUT_SECONDS +from tests.suite.screens.screen import Screen + + +def run_to_its_end(screen: Screen) -> None: + """Presses the button naming the run and waits for the question its end asks, which it closes.""" + converter = screen.main.converter + converter.press_action() + wait_for_the_end(screen) + + +def wait_for_the_end(screen: Screen) -> None: + """Waits for the question the run's end asks and closes it.""" + converter = screen.main.converter + screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end closed") + + +def written(destination: Path) -> List[Path]: + """The reconstruction files below ``destination``, sorted.""" + return sorted(destination.rglob(f"*{RECONSTRUCTION_SUFFIX}")) + + +def modified(paths: List[Path]) -> Dict[Path, int]: + """The modification time of each of ``paths``, in nanoseconds.""" + return {path: path.stat().st_mtime_ns for path in paths} diff --git a/tests/screens/main/run/test_button_and_lines.py b/tests/screens/main/run/test_button_and_lines.py new file mode 100644 index 000000000..aabb96993 --- /dev/null +++ b/tests/screens/main/run/test_button_and_lines.py @@ -0,0 +1,106 @@ +from typing import Final + +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_GROUP_INPUT, TAG_MAIN_CONVERTER_GROUP_ORDER +from tests.screens.main.run.constants import BASS, LEAD, RUN_TIMEOUT_SECONDS +from tests.screens.main.run.steps import wait_for_the_end +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import CONVERT_NOTHING, CONVERT_ONE, CONVERT_SEVERAL + +MIX_SEVERAL: Final[str] = "main.converter.template.mix_recordings" + + +class TestTheButtonNamesTheRun: + """The button under the list names the run each time the list or the switch changes. + + One recording is gathered, then a second, then the switch turned to a mix, then both removed. The + button reads, in turn, Convert one, Convert several, the mix, and Convert nothing. + """ + + def test_one_several_a_mix_and_none(self, screen: Screen) -> None: + main = screen.main + converter = main.converter + + def one(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(BASS).stem) + + def several(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def a_mix(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(converter.action, screen.words(MIX_SEVERAL).format(count=2).__eq__, description="a mix named") + + def none(screen: Screen) -> None: + converter.list.remove(home_path(BASS)) + converter.list.remove(home_path(LEAD)) + + screen.expect(converter.action, screen.words(CONVERT_NOTHING).__eq__, description="nothing to name") + + screen.scenario(one, several, a_mix, none).run() + + +class TestTheLinesOfTheCard: + """Order joins from a mix's second recording, Destination always stands, and Input shows only while + a run reads. + """ + + def test_order_and_destination(self, screen: Screen) -> None: + """A mix of one shows no Order line, the second recording brings it; Destination always shows.""" + main = screen.main + converter = main.converter + + def order_waits_for_a_second_recording_in_a_mix(screen: Screen) -> None: + assert converter.destination() + gather(screen, home_path(BASS)) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown + + def the_second_brings_it(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown, + bool, + description="Order joining", + ) + assert converter.destination() + + screen.scenario(order_waits_for_a_second_recording_in_a_mix, the_second_brings_it).run() + + def test_input_stands_while_a_run_reads(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """The Input line is hidden before a held run, shown while it reads and hidden again after its end.""" + converter = screen.main.converter + + def no_input_before_the_run(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + def input_while_it_reads(screen: Screen) -> None: + converter.press_action() + + screen.bridge.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown, + bool, + description="the Input line", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def no_input_after_it(screen: Screen) -> None: + conversion_hold.release() + wait_for_the_end(screen) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + screen.scenario(no_input_before_the_run, input_while_it_reads, no_input_after_it).run() diff --git a/tests/screens/main/run/test_destination.py b/tests/screens/main/run/test_destination.py new file mode 100644 index 000000000..647837e3c --- /dev/null +++ b/tests/screens/main/run/test_destination.py @@ -0,0 +1,99 @@ +from pathlib import Path +from typing import Dict, FrozenSet, List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.main.run.constants import ALBUM, ALBUM_TAKES, BASS, DISC, DISC_TAKES, LEAD, RECONSTRUCTION_SUFFIX +from tests.screens.main.run.steps import modified, run_to_its_end, written +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, give_each_its_own_channel, home_path + + +def converted_channels(path: Path) -> FrozenSet[ChannelName]: + """The channels a written reconstruction approximates, read off the file.""" + return frozenset(Reconstruction.load(path).approximations) + + +def written_as(path: Path) -> Path: + """The one reconstruction a run wrote for the recording at ``path``, wherever below the folder it went.""" + found = [file for file in written(RECONSTRUCTIONS_DIRECTORY) if file.stem == path.stem] + assert len(found) == 1, found + return found[0] + + +class TestWhereARunWrites: + """A run writes each recording with the channels its row gave, where the Destination line says.""" + + def channels(self) -> Dict[Path, ChannelName]: + """The channel each gathered recording is given: the bass Triangle, the lead Pulse 1.""" + return {home_path(BASS): ChannelName.TRIANGLE, home_path(LEAD): ChannelName.PULSE1} + + def test_each_recording_carries_the_channels_its_row_gave(self, screen: Screen) -> None: + """Each written reconstruction approximates only the channel its recording was given.""" + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + + run_to_its_end(screen) + + for path, channel in self.channels().items(): + assert converted_channels(written_as(path)) == {channel} + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: Destination names no folder a run of differing channels writes into", + ) + def test_the_destination_line_names_the_folder_the_run_writes_into(self, screen: Screen) -> None: + """Every reconstruction is written directly in the folder the Destination line names.""" + converter = screen.main.converter + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + destination = Path(converter.destination()) + + run_to_its_end(screen) + + assert all(written_as(path).parent == destination for path in self.channels()) + + +class TestAFolderConverts: + """A listed folder converts into a tree mirroring it; a rerun replaces nothing already written and + makes the rest. + + The Album folder, with its nested Disc2 folder, is gathered and converted. One written file is then + deleted and the run repeated; the deleted file returns and the others keep their modification times. + """ + + def test_a_mirrored_tree_then_only_what_is_missing(self, screen: Screen) -> None: + converter = screen.main.converter + folder = home_path(ALBUM) + expected = [Path(name).with_suffix(RECONSTRUCTION_SUFFIX) for name in ALBUM_TAKES] + [ + (Path(DISC) / name).with_suffix(RECONSTRUCTION_SUFFIX) for name in DISC_TAKES + ] + kept: List[Dict[Path, int]] = [] + destinations: List[Path] = [] + + def convert_the_folder(screen: Screen) -> None: + gather(screen, folder) + destinations.append(Path(converter.destination())) + + run_to_its_end(screen) + + files = written(destinations[0]) + assert len(files) == len(expected) + assert all(any(file.as_posix().endswith(part.as_posix()) for file in files) for part in expected) + + def rerun_makes_only_what_is_missing(screen: Screen) -> None: + files = written(destinations[0]) + missing, *standing = files + missing.unlink() + kept.append(modified(standing)) + + run_to_its_end(screen) + + assert written(destinations[0]) == files + assert modified(standing) == kept[0] + + screen.scenario(convert_the_folder, rerun_makes_only_what_is_missing).run() diff --git a/tests/screens/main/run/test_progress_and_failure.py b/tests/screens/main/run/test_progress_and_failure.py new file mode 100644 index 000000000..ce45d7b95 --- /dev/null +++ b/tests/screens/main/run/test_progress_and_failure.py @@ -0,0 +1,96 @@ +import multiprocessing +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from tests.screens.main.run.constants import BASS, RUN_TIMEOUT_SECONDS +from tests.screens.main.run.steps import written +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, CONVERT_ONE + +RUN_FAILED: Final[str] = "main.converter.message.status_error" + + +class TestAHeldRun: + """A run shows its progress, Continue lets it run on, and Stop ends it with nothing written and every + worker gone. + + A held run starts on one recording and the button turns to its cancel label. Pressing it asks about + stopping; Cancel on that question keeps the run going. Pressing again and confirming ends the run, + restores the Convert label and leaves no worker process and no written file. + """ + + @pytest.mark.usefixtures("conversion_hold") + def test_progress_continue_and_stop(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.cancel_prompt + destinations: List[Path] = [] + + def start_it(screen: Screen) -> None: + gather(screen, home_path(BASS)) + destinations.append(Path(converter.destination())) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def continue_lets_it_run(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about stopping") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert converter.action() == screen.words(CANCEL_RUN) + + def stop_ends_it(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.bridge.expect( + lambda: len(multiprocessing.active_children()), + operator.not_, + description="no worker left", + timeout=RUN_TIMEOUT_SECONDS, + ) + assert written(destinations[0]) == [] + + screen.scenario(start_it, continue_lets_it_run, stop_ends_it).run() + + +class TestARecordingGoneBeforeTheRun: + """A recording deleted after it was gathered is reported in words naming it when the run reaches it.""" + + def test_the_run_says_what_went_wrong(self, screen: Screen) -> None: + """An error notice opens with the run-failed words and the file's name, and the status line reads the + run-failed words. + """ + converter = screen.main.converter + notice = screen.error_notice + gather(screen, home_path(BASS)) + home_path(BASS).unlink() + + converter.press_action() + + screen.bridge.expect(notice.is_shown, bool, description="the failure reported", timeout=RUN_TIMEOUT_SECONDS) + words = notice.words() + screen.claim_error(home_path(BASS).name) + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the report dismissed") + assert words.startswith(screen.words(RUN_FAILED)) + assert home_path(BASS).name in words + assert converter.status() == screen.words(RUN_FAILED) diff --git a/tests/screens/main/run/test_replacing.py b/tests/screens/main/run/test_replacing.py new file mode 100644 index 000000000..b7a721d75 --- /dev/null +++ b/tests/screens/main/run/test_replacing.py @@ -0,0 +1,112 @@ +import operator +import shutil +from pathlib import Path +from typing import Dict, Final, List + +from tests.screens.main.run.constants import BASS, DRUMS, LEAD, RECONSTRUCTION_SUFFIX +from tests.screens.main.run.steps import modified, run_to_its_end, wait_for_the_end, written +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, gather, home_path + +OTHER_RECONSTRUCTION: Final[str] = "other.stn" +RECONSTRUCT_FILE: Final[str] = "main.explorer.label.context_reconstruct_file" + + +class TestReplacingWhatARunWrote: + """Recordings gathered by name and converted before ask first, and Convert anyway replaces exactly + them. + + Three recordings are converted and a fourth reconstruction is copied beside them. Running again asks + about replacing; Cancel keeps every file as it was, and Convert anyway rewrites the three while the + fourth keeps its modification time. + """ + + def test_cancel_writes_nothing_and_convert_anyway_replaces_those_alone(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.overwrite_prompt + paths = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] + state: Dict[str, Dict[Path, int]] = {} + destinations: List[Path] = [] + + def convert_three_and_set_another_beside_them(screen: Screen) -> None: + gather(screen, *paths) + destinations.append(Path(converter.destination())) + run_to_its_end(screen) + first = written(destinations[0])[0] + shutil.copyfile(first, destinations[0] / OTHER_RECONSTRUCTION) + state["before"] = modified(written(destinations[0])) + + def cancel_writes_nothing(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about replacing") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert modified(written(destinations[0])) == state["before"] + + def convert_anyway_replaces_those_alone(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + after = modified(written(destinations[0])) + other = destinations[0] / OTHER_RECONSTRUCTION + assert after[other] == state["before"][other] + assert all( + after[file] != state["before"][file] + for file in (destinations[0] / f"{path.stem}{RECONSTRUCTION_SUFFIX}" for path in paths) + ) + + screen.scenario( + convert_three_and_set_another_beside_them, + cancel_writes_nothing, + convert_anyway_replaces_those_alone, + ).run() + + +class TestReconstructingAFileWhileAListStands: + """Reconstruct file with a list gathered asks first: Keep the list converts nothing, Replace it + converts the file. + + Two recordings are gathered and Reconstruct file chosen from the explorer's menu on a third. + Cancel keeps the list and writes nothing. Choosing it again and confirming runs the third alone. + """ + + def test_keep_the_list_then_replace_it(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.replace_prompt + destinations: List[Path] = [] + + def ask_from_the_browser(screen: Screen) -> None: + gather(screen, home_path(BASS), home_path(LEAD)) + destinations.append(Path(converter.destination())) + screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) + + screen.expect(prompt.is_shown, bool, description="the question about the list") + + def keep_the_list(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert converter.list.rows() == [converter.list.row(home_path(BASS)), converter.list.row(home_path(LEAD))] + assert written(destinations[0]) == [] + + def replace_it(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) + screen.expect(screen.context_menu.is_shown, bool, description="the menu again") + screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + assert [file.stem for file in written(destinations[0])] == [home_path(DRUMS).stem] + + screen.scenario(ask_from_the_browser, keep_the_list, replace_it).run() diff --git a/tests/screens/main/scan/__init__.py b/tests/screens/main/scan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/scan/test_reading_a_folder.py b/tests/screens/main/scan/test_reading_a_folder.py new file mode 100644 index 000000000..3bc6bbabd --- /dev/null +++ b/tests/screens/main/scan/test_reading_a_folder.py @@ -0,0 +1,208 @@ +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.holds.base import Holds +from tests.suite.screens.holds.scan import ScanHold, WindingDownScanHold +from tests.suite.screens.holds.signal import ReleaseSignal +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW, SCAN_PROGRESS +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import lived_in_world + +MANY: Final[str] = "Many" +FEW: Final[str] = "Few" +MANY_RECORDINGS: Final[int] = 130 +FEW_RECORDINGS: Final[int] = 2 +REPORTED_COUNT: Final[int] = 128 +SECONDS: Final[float] = 0.05 +FREQUENCY: Final[float] = 330.0 +WIND_DOWN_RELEASE_FILE: Final[str] = "release-winding-down-scan" +RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" + + +def folder_of(name: str, count: int) -> List[HomeFile]: + """The recordings of a folder named ``name`` holding ``count`` short takes.""" + return [ + Recording(destination=home_path(name) / f"take{index:03d}.wav", seconds=SECONDS, frequency=FREQUENCY) + for index in range(count) + ] + + +@pytest.fixture +def world() -> World: + """A lived-in home holding a folder of many recordings and a folder of two.""" + lived_in = lived_in_world() + return World( + state=lived_in.state, + application_config=None, + config=None, + files=(*folder_of(MANY, MANY_RECORDINGS), *folder_of(FEW, FEW_RECORDINGS)), + ) + + +def folder_label(screen: Screen, name: str, count: int) -> str: + """The text of a folder's row in the list, naming the folder and its recordings.""" + return screen.words(FOLDER_ROW).format(name=name, count=count) + + +class TestReadingALargeFolder: + """A folder being read shows a window naming it and counting what it found, while the rest of the + interface answers. + + The large folder is Ctrl-clicked and a held read begins. The window reports the count so far, the + Sequencer and Main tabs come to the front in turn, and releasing the read closes the window and + gathers the folder as one row. + """ + + def test_the_window_counts_and_the_interface_answers_meanwhile(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def start_reading(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + + expected = screen.words(SCAN_PROGRESS).format(count=REPORTED_COUNT, name=MANY) + screen.expect(converter.scan_words, expected.__eq__, description="the count of the folder read so far") + assert converter.scan_shown() + + def the_interface_answers(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + assert converter.scan_shown() + assert converter.list.hint_shown() + + def the_read_lands(screen: Screen) -> None: + scan_hold.release() + + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + screen.expect( + lambda: converter.list.has_row(home_path(MANY)), + bool, + description="the folder gathered", + ) + assert converter.list.label(home_path(MANY)) == folder_label(screen, MANY, MANY_RECORDINGS) + + screen.scenario(start_reading, the_interface_answers, the_read_lands).run() + + +class TestStoppingARead: + """Stop gives the read up and gathers nothing; the next Ctrl-click reads afresh. + + The large folder is read and stopped, then the small folder is Ctrl-clicked and released. The list + ends with the small folder's row alone. + """ + + def test_stop_gathers_nothing_and_the_next_read_runs(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def stop_a_read(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + + converter.stop_scan() + + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + def the_next_read_runs(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) + + screen.expect(converter.scan_shown, bool, description="a new read under way") + scan_hold.release() + screen.expect( + lambda: converter.list.has_row(home_path(FEW)), bool, description="the second folder gathered" + ) + assert converter.list.rows() == [converter.list.row(home_path(FEW))] + + screen.scenario(stop_a_read, the_next_read_runs).run() + + +class TestAskingForAFolderDuringARead: + """A folder asked for while another is being read waits; asking again once the window closes reads it. + + The large folder is read and the small folder's Reconstruct menu entry chosen meanwhile. The read + under way goes on and the list gains only the large folder. Ctrl-clicking the small folder after + the window closes gathers it. + """ + + def test_the_read_under_way_runs_on_and_the_folder_asked_for_reads_afterwards( + self, + screen: Screen, + scan_hold: ScanHold, + ) -> None: + converter = screen.main.converter + + def ask_for_a_second_folder_during_a_read(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + + screen.explorer.right_click(explorer_row(screen, home_path(FEW))) + screen.expect(screen.context_menu.is_shown, bool, description="the second folder's menu") + screen.context_menu.choose(screen.words(RECONSTRUCT_DIRECTORY)) + + screen.expect(screen.context_menu.is_shown, operator.not_, description="the menu put away") + assert converter.scan_shown() + assert MANY in converter.scan_words() + + def the_first_read_lands(screen: Screen) -> None: + scan_hold.release() + + screen.expect( + lambda: converter.list.has_row(home_path(MANY)), bool, description="the first folder gathered" + ) + assert not converter.run_shown() + + def asking_again_reads_it(screen: Screen) -> None: + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) + + screen.expect( + lambda: converter.list.has_row(home_path(FEW)), bool, description="the second folder gathered" + ) + + screen.scenario( + ask_for_a_second_folder_during_a_read, + the_first_read_lands, + asking_again_reads_it, + ).run() + + +class TestAskingForAFolderAsAStoppedReadWindsDown: + """A folder asked for once Stop closed the window is read, as the scan promises once its window closes. + + The read of the large folder is held past its last entry, so Stop closes the window while the read still + winds down. The small folder is Ctrl-clicked then, and the hold lets the stopped read end. + """ + + @pytest.fixture + def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> WindingDownScanHold: + """A scan hold that keeps a stopped read winding down until the scenario releases it.""" + hold = WindingDownScanHold(ReleaseSignal(Path.cwd().parent / WIND_DOWN_RELEASE_FILE)) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a folder asked for while a stopped read winds down is dropped", + ) + def test_the_folder_asked_for_is_read(self, screen: Screen, scan_hold: WindingDownScanHold) -> None: + """The small folder, Ctrl-clicked just after Stop, is gathered once the hold is released.""" + converter = screen.main.converter + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + converter.stop_scan() + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) + + scan_hold.release() + screen.expect(lambda: converter.list.has_row(home_path(FEW)), bool, description="the folder asked for gathered") diff --git a/tests/screens/prompts/__init__.py b/tests/screens/prompts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/closing/__init__.py b/tests/screens/prompts/closing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/closing/constants.py b/tests/screens/prompts/closing/constants.py new file mode 100644 index 000000000..256e347f9 --- /dev/null +++ b/tests/screens/prompts/closing/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 30 diff --git a/tests/screens/prompts/closing/test_during_an_edit.py b/tests/screens/prompts/closing/test_during_an_edit.py new file mode 100644 index 000000000..f33bff2c4 --- /dev/null +++ b/tests/screens/prompts/closing/test_during_an_edit.py @@ -0,0 +1,121 @@ +import operator +from typing import List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.closing.constants import SETTLING_FRAMES +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + first_raised, + marked, + raise_the_first_level_while_held, + titled, +) +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + + +class TestClosingTheWindowWhileAnEditIsOnItsWay: + """A close asked for during an edit asks about the edited reconstruction once the edit lands.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: two closes before the first is answered ask twice", + ) + def test_two_closes_ask_once(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """Two closes during a held rebuild bring one question, and Cancel leaves the screen quiet.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + + def edit_and_close_twice_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + screen.close_window() + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_edit_lands_and_one_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + screen.scenario( + edit_and_close_twice_while_the_rebuild_is_held, + the_edit_lands_and_one_question_comes, + cancel_leaves_no_question, + ).run() + + def test_an_edit_made_after_the_close_is_drawn_away( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """An edit typed after the close is dropped, and the field shows the edit that landed.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_and_close_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def edit_again_after_the_close(screen: Screen) -> None: + reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + typed[0].__eq__, + description="the field drawing the edit that landed", + ) + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving again") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_and_close_while_the_rebuild_is_held, + edit_again_after_the_close, + the_first_edit_lands_and_the_second_is_drawn_away, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/closing/test_over_a_question.py b/tests/screens/prompts/closing/test_over_a_question.py new file mode 100644 index 000000000..a9a770d85 --- /dev/null +++ b/tests/screens/prompts/closing/test_over_a_question.py @@ -0,0 +1,177 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.prompts.closing.constants import SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from tests.suite.screens.worlds.recordings import BASS, LEAD + +PROJECT: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" +NEW_TITLE: Final[str] = "After closing" + + +def change_a_saved_project(screen: Screen) -> None: + """Saves a new titled project, then retitles it, which leaves the saved file with the first title.""" + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT) + + retitle_project(screen, NEW_TITLE) + + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Answers Exit to the question about the project and expects the saved file to keep its first title.""" + prompt = screen.project.unsaved_prompt + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + +class TestClosingTheWindowTwiceAtOnce: + """Two closes before the first is answered ask about the unsaved project once. + + The saved project is retitled and the window closed twice. One question comes; Cancel keeps the + application, and a further close asks once more and leaves on Exit. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: two closes before the first is answered ask twice", + ) + def test_one_question_and_cancel_keeps_the_application(self, screen: Screen) -> None: + """Two closes bring one question, and Cancel keeps the application running.""" + prompt = screen.project.unsaved_prompt + + def close_twice(screen: Screen) -> None: + screen.close_window() + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.is_running() + + def close_once_more(screen: Screen) -> None: + screen.close_window() + + leave_letting_the_project_go(screen) + + screen.scenario(change_a_saved_project, close_twice, cancel_leaves_no_question, close_once_more).run() + + +class TestClosingTheWindowOverTheReassignQuestion: + """Closing the window while Keyboard settings asks to reassign keys asks about the project after the + dialog. + + The saved project is retitled, Redo listens and Undo's keys ask to reassign. The window close waits; + Cancel brings the dialog back, and closing the dialog brings the question about the project. + """ + + def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + """The question about the project comes after the reassign question and its dialog.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + prompt = screen.project.unsaved_prompt + + def ask_to_reassign(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert reassign.is_shown() + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def answer_the_dialog_and_its_question(screen: Screen) -> None: + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + + settings.cancel() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert len(screen.shown_windows()) == 1 + + screen.scenario( + change_a_saved_project, + ask_to_reassign, + close_the_window, + answer_the_dialog_and_its_question, + leave_letting_the_project_go, + ).run() + + +class TestClosingTheWindowOverTheOverwriteQuestion: + """Closing the window while the Converter asks to replace a run's files asks about the project after it. + + The saved project is retitled and a run is converted again, which asks about replacing. The window + close waits; Cancel brings the question about the project. + """ + + def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + """The question about the project comes after the overwrite question.""" + converter = screen.main.converter + overwrite = converter.overwrite_prompt + prompt = screen.project.unsaved_prompt + + def convert_and_ask_to_convert_again(screen: Screen) -> None: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the run's end answered") + + converter.press_action() + + screen.expect(overwrite.is_shown, bool, description="the question about replacing") + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert overwrite.is_shown() + assert not prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + overwrite.cancel() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert not converter.run_shown() + assert len(screen.shown_windows()) == 1 + + screen.scenario( + change_a_saved_project, + convert_and_ask_to_convert_again, + close_the_window, + cancel_hands_the_screen_over, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/conftest.py b/tests/screens/prompts/conftest.py new file mode 100644 index 000000000..7c20032b0 --- /dev/null +++ b/tests/screens/prompts/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import documents_world + + +@pytest.fixture +def world() -> World: + """The home every prompt scenario starts in: documents to put away and replace, and recordings to convert.""" + return documents_world() diff --git a/tests/screens/prompts/every_door/__init__.py b/tests/screens/prompts/every_door/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/every_door/cases.py b/tests/screens/prompts/every_door/cases.py new file mode 100644 index 000000000..1527eca5a --- /dev/null +++ b/tests/screens/prompts/every_door/cases.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Final, Tuple + +from tests.screens.prompts.every_door.constants import EXIT +from tests.suite.screens.vocabulary.dialogs import CLOSE + + +class Door(StrEnum): + """A gesture that puts the open document away, to replace it or to leave. + + A voice opens from a double-click on its row and from Edit on its row's menu. + """ + + BROWSER = "browser" + MENU_OPEN = "menu_open" + CLOSE = "close" + VOICE = "voice" + VOICE_MENU = "voice_menu" + CONVERSION_LOAD = "conversion_load" + EXIT = "exit" + WINDOW_CLOSE = "window_close" + + +ASKING_DOORS: Final[Tuple[Door, ...]] = tuple(Door) + + +@dataclass(frozen=True) +class Question: + """A question as the reader meets it, with its title, its words and the answers it offers.""" + + title: str + words: str + answers: Tuple[str, ...] + + +@dataclass(frozen=True) +class Standing: + """What the screen shows of the open document, which a question taken back keeps as it was.""" + + title: str + file_line: str + volume: str + windows: Tuple[str, ...] + dialogs: int diff --git a/tests/screens/prompts/every_door/constants.py b/tests/screens/prompts/every_door/constants.py new file mode 100644 index 000000000..cdb4c3fba --- /dev/null +++ b/tests/screens/prompts/every_door/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 +SAMPLE_ORDINAL: Final[int] = 0 +EXIT: Final[str] = "global.dialog.label.exit" diff --git a/tests/screens/prompts/every_door/steps.py b/tests/screens/prompts/every_door/steps.py new file mode 100644 index 000000000..12b445d50 --- /dev/null +++ b/tests/screens/prompts/every_door/steps.py @@ -0,0 +1,48 @@ +import operator + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.reconstructions import load_from_the_browser +from tests.suite.screens.steps.sequencer import double_click_voice, open_voice +from tests.suite.screens.worlds.recordings import BASS, LEAD, OTHER_RECONSTRUCTION + + +def knock(screen: Screen, door: Door, *, voice: str) -> None: + """Makes the gesture ``door`` stands for. + + A door that loads opens Other.stn, the run's bass, or ``voice``. + """ + match door: + case Door.BROWSER: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + case Door.MENU_OPEN: + screen.reconstructions.open_from_menu() + case Door.CLOSE: + screen.reconstructions.close_from_menu() + case Door.VOICE: + double_click_voice(screen, voice) + case Door.VOICE_MENU: + open_voice(screen, voice) + case Door.CONVERSION_LOAD: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + screen.main.converter.end_prompt.confirm() + screen.expect(screen.main.converter.end_prompt.is_shown, operator.not_, description="the end answered") + case Door.EXIT: + screen.press_shortcut(ShortcutId.EXIT) + case Door.WINDOW_CLOSE: + screen.close_window() + + +def nothing_asked(screen: Screen) -> None: + """Waits a few frames and expects every window to stay closed.""" + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + +def expect_title(screen: Screen, title: str) -> None: + """Waits until the window title reads ``title``.""" + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") diff --git a/tests/screens/prompts/every_door/test_edited_reconstruction.py b/tests/screens/prompts/every_door/test_edited_reconstruction.py new file mode 100644 index 000000000..025a3f9e7 --- /dev/null +++ b/tests/screens/prompts/every_door/test_edited_reconstruction.py @@ -0,0 +1,180 @@ +import operator +from typing import Callable, Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import ASKING_DOORS, Door, Question, Standing +from tests.screens.prompts.every_door.constants import EXIT, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + marked, + raise_the_first_level, + remove_from_the_browser, + titled, +) +from tests.suite.screens.vocabulary.dialogs import ( + CANCEL, + CLOSE, + CLOSE_MESSAGE, + CLOSE_TITLE, + DISCARD, + EDIT_MESSAGE, + EDIT_TITLE, + EXIT_RECONSTRUCTION_MESSAGE, + LOAD_MESSAGE, + LOAD_TITLE, + SAVE, +) +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_SAMPLE + +EXIT_TITLE: Final[str] = "global.dialog.title.exit_confirmation" + + +def question_at(screen: Screen, door: Door) -> Question: + """The question ``door`` asks about a standalone reconstruction with unsaved changes, in the words of the + language file. + """ + match door: + case Door.BROWSER | Door.MENU_OPEN | Door.CONVERSION_LOAD: + return Question( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.CLOSE: + return Question( + screen.words(CLOSE_TITLE), + screen.words(CLOSE_MESSAGE), + (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)), + ) + case Door.VOICE | Door.VOICE_MENU: + return Question( + screen.words(EDIT_TITLE), + screen.words(EDIT_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.EXIT | Door.WINDOW_CLOSE: + return Question( + screen.words(EXIT_TITLE), + screen.words(EXIT_RECONSTRUCTION_MESSAGE), + (screen.words(SAVE), screen.words(EXIT), screen.words(CANCEL)), + ) + + +def standing(screen: Screen) -> Standing: + """What the screen shows of the open reconstruction right now.""" + reconstructions = screen.reconstructions + return Standing( + title=screen.title(), + file_line=reconstructions.file_line(), + volume=reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + windows=tuple(window.alias for window in screen.shown_windows()), + dialogs=len(screen.dialog_requests()), + ) + + +def asked(screen: Screen, door: Door) -> Question: + """Waits for the question about the reconstruction and returns it as the screen shows it.""" + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description=f"the question {door} asks") + return Question(prompt.title(), prompt.words(), prompt.answers()) + + +def asks_and_cancel_keeps_it(door: Door, *, voice: str) -> Callable[[Screen], None]: + """The step that knocks at ``door``, expects its question, and takes it back with Cancel, which keeps + everything as it stood. + """ + + def step(screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + before = standing(screen) + + knock(screen, door, voice=voice) + + assert asked(screen, door) == question_at(screen, door) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert standing(screen) == before + + step.__name__ = f"{door.value}_asks_and_cancel_keeps_it" + return step + + +def leave_letting_it_go(screen: Screen) -> None: + """Exits and answers Exit to the question about the reconstruction.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the reconstruction") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestAnEditedReconstructionAtEveryDoor: + """Every door asks about an edited reconstruction in its own words, and Cancel keeps everything as it + stood. + + The reconstruction is open beside a project and its first level is raised. Each door is knocked at in + turn; its question is compared with the expected title, words and answers, and Cancel leaves the title, + file line, volume and windows as before. The scenario ends by exiting. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + """Each door asks its own question and Cancel keeps the screen as it was.""" + + def edit_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + screen.scenario( + edit_it, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() + + +class TestAReconstructionWhoseFileWasRemovedAtEveryDoor: + """Every door asks about a reconstruction whose file was removed, as it asks about an edited one. + + The open reconstruction loses its file in the browser and shows as unsaved. Each door is knocked at in + turn, asks its question, and Cancel keeps everything as it stood. The scenario ends by exiting. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + """Each door asks its own question and Cancel keeps the screen as it was.""" + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + expect_title(screen, titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True))) + + screen.scenario( + remove_its_file, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_edited_sample.py b/tests/screens/prompts/every_door/test_edited_sample.py new file mode 100644 index 000000000..384ef7c0d --- /dev/null +++ b/tests/screens/prompts/every_door/test_edited_sample.py @@ -0,0 +1,119 @@ +import operator +from functools import partial +from typing import Callable, List + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import converted, marked, raise_the_first_level, titled, voice_title +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + + +class TestAnEditedSampleAtEveryDoor: + """An edited sample of the project is put away at every door with no question, and its edits stay in the + project. + + The sample is opened and edited. The browser, the Open menu, another voice, Close and a conversion each + go through at once, and the edited sample is still there when it is opened again. Only the exit and the + window close ask, and they ask about the project. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_the_exit_asks_about_the_project(self, screen: Screen) -> None: + """The doors go through at once and the exits ask about the project.""" + reconstructions = screen.reconstructions + edit: List[str] = [] + sample_open = partial(voice_title, screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True) + + def open_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, sample_open()) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] + + def edit_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + + edit.append(raise_the_first_level(screen, ChannelName.PULSE1, title=sample_open())) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def another_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_INSTRUMENT) + + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + assert screen.title() == titled(screen, marked(SONG.stem, unsaved=True)) + nothing_asked(screen) + open_the_sample(screen) + + def close_closes_at_once(screen: Screen) -> None: + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True))) + assert reconstructions.file_line() == "" + nothing_asked(screen) + open_the_sample(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_sample(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert not reconstructions.unsaved_prompt.is_shown() + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.title() == sample_open() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_sample, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + another_voice_opens_at_once, + close_closes_at_once, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_instrument.py b/tests/screens/prompts/every_door/test_instrument.py new file mode 100644 index 000000000..37b43cc9d --- /dev/null +++ b/tests/screens/prompts/every_door/test_instrument.py @@ -0,0 +1,126 @@ +import operator +from typing import Callable, Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import converted, edit_envelope, marked, titled, voice_title +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +FADING: Final[str] = "12 8 4" + + +class TestAnInstrumentAtEveryDoor: + """An instrument is put away at every door with no question, Close is unavailable, and the exit asks about + the project. + + The instrument is opened and its volume envelope edited. The browser, the Open menu, a sample and a + conversion each go through at once, and the edited instrument is still there when it is opened again. + Close does nothing, and the exit and the window close ask about the project. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_close_stands_aside(self, screen: Screen) -> None: + """The doors go through at once, Close is unavailable and the exits ask about the project.""" + reconstructions = screen.reconstructions + edited = titled(screen, marked(SONG.stem, unsaved=True)) + + def open_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="its edit drawn", + ) + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + + edit_envelope(screen, channel=ChannelName.PULSE1, feature=FeatureKey.VOLUME, sequence=FADING, title=edited) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + assert not reconstructions.instruments.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_instrument(screen) + + def a_sample_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True)) + assert not reconstructions.instruments.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert reconstructions.instruments.offers_audition() + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING + assert screen.title() == edited + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_instrument(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert reconstructions.instruments.offers_audition() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_instrument, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_sample_opens_at_once, + close_has_nothing_to_close, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_nothing_open.py b/tests/screens/prompts/every_door/test_nothing_open.py new file mode 100644 index 000000000..d8baf834d --- /dev/null +++ b/tests/screens/prompts/every_door/test_nothing_open.py @@ -0,0 +1,83 @@ +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.reconstructions import converted, titled, voice_title +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_SAMPLE + + +class TestNothingOpenAtEveryDoor: + """With nothing open, every door that loads loads at once, and Close is unavailable.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_door_loads_at_once(self, screen: Screen) -> None: + """The browser, the Open menu, a voice and a conversion each load at once and the title follows.""" + reconstructions = screen.reconstructions + empty = titled(screen, SONG.stem) + + def close_it_again(screen: Screen) -> None: + reconstructions.close_from_menu() + expect_title(screen, empty) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert screen.title() == empty + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + close_it_again(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + screen.scenario( + close_has_nothing_to_close, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_voice_opens_at_once, + the_conversion_loads_at_once, + ).run() + + def test_the_window_closes_at_once(self, screen: Screen) -> None: + """Closing the window leaves the application at once.""" + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the song open") + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/every_door/test_saved_reconstruction.py b/tests/screens/prompts/every_door/test_saved_reconstruction.py new file mode 100644 index 000000000..3b327c36d --- /dev/null +++ b/tests/screens/prompts/every_door/test_saved_reconstruction.py @@ -0,0 +1,85 @@ +import pytest + +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.reconstructions import converted, expect_open, load_from_the_browser, titled, voice_title +from tests.suite.screens.worlds.recordings import BASS, OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION, SONG, SONG_SAMPLE + + +class TestASavedReconstructionAtEveryDoor: + """A reconstruction with nothing unsaved is put away at every door at once, and leaving leaves.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_no_door_asks(self, screen: Screen) -> None: + """Every door replaces, closes or leaves at once.""" + + def the_browser_loads_at_once(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OPEN_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OPEN_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + + def close_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem)) + nothing_asked(screen) + + def the_window_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() + + screen.scenario( + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + the_conversion_loads_at_once, + a_voice_opens_at_once, + close_closes_at_once, + the_window_closes_at_once, + ).run() + + def test_exit_leaves_at_once(self, screen: Screen) -> None: + """The exit shortcut leaves the application at once.""" + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.EXIT, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/keyboard/__init__.py b/tests/screens/prompts/keyboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/keyboard/constants.py b/tests/screens/prompts/keyboard/constants.py new file mode 100644 index 000000000..e20e3358d --- /dev/null +++ b/tests/screens/prompts/keyboard/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 diff --git a/tests/screens/prompts/keyboard/steps.py b/tests/screens/prompts/keyboard/steps.py new file mode 100644 index 000000000..0154f8229 --- /dev/null +++ b/tests/screens/prompts/keyboard/steps.py @@ -0,0 +1,18 @@ +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + + +def tab(screen: Screen, times: int) -> None: + """Presses the shortcut for the next control in a dialog ``times`` times.""" + for _ in range(times): + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + + +def enter(screen: Screen) -> None: + """Presses the shortcut that activates the focused control of a dialog.""" + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + +def escape(screen: Screen) -> None: + """Presses the shortcut that cancels a dialog.""" + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) diff --git a/tests/screens/prompts/keyboard/test_display_question.py b/tests/screens/prompts/keyboard/test_display_question.py new file mode 100644 index 000000000..6fe8098dc --- /dev/null +++ b/tests/screens/prompts/keyboard/test_display_question.py @@ -0,0 +1,56 @@ +import operator +from typing import List + +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.screen import Screen + + +class TestTheDisplayQuestionFromTheKeyboard: + """The question Escape asks of changed Display settings answers to the keys, as does the dialog after it. + + The user flips Vertical sync and presses Escape. Enter keeps the dialog open with the change. Escape + again, then Tab and Enter, discards the change; the dialog reopens with the original value. + """ + + def test_enter_keeps_editing_and_tab_enter_discards(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + def escape_asks(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, bool, description="the discard question") + + def enter_keeps_editing(screen: Screen) -> None: + enter(screen) + + screen.expect(settings.is_shown, bool, description="the dialog back") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def escape_then_tab_and_enter_discard(screen: Screen) -> None: + escape(screen) + screen.expect(prompt.is_shown, bool, description="the discard question again") + + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not settings.is_shown() + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog opened again") + assert settings.vsync() == original[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed") + + screen.scenario(change_vertical_sync, escape_asks, enter_keeps_editing, escape_then_tab_and_enter_discard).run() diff --git a/tests/screens/prompts/keyboard/test_exit_chain.py b/tests/screens/prompts/keyboard/test_exit_chain.py new file mode 100644 index 000000000..9ef3fcbae --- /dev/null +++ b/tests/screens/prompts/keyboard/test_exit_chain.py @@ -0,0 +1,103 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.keyboard.constants import SETTLING_FRAMES +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, saved_project_title +from tests.suite.screens.steps.reconstructions import expect_open, marked, raise_the_first_level, titled +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE, EXIT_RECONSTRUCTION_MESSAGE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +RETITLED: Final[str] = "Retitled from the keyboard" + + +class TestTheExitChainFromTheKeyboard: + """Tab, Enter and Escape answer the question about the reconstruction that the project's answer opens. + + Each question starts on Cancel, so one Tab reaches Save and two reach Exit. The scenario opens a + project beside an edited reconstruction and retitles the project. Exit asks about the project, and Exit + on that question asks about the reconstruction. Escape there keeps the application and both changes. + Then the keys save the project, leave the reconstruction's file as it was, and close the application. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_second_keeps_everything_and_the_keys_then_save_and_leave(self, screen: Screen) -> None: + project_prompt = screen.project.unsaved_prompt + reconstruction_prompt = screen.reconstructions.unsaved_prompt + stored: List[bytes] = [] + + def open_the_project_and_change_both(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + screen.answer_next_dialog(DialogKind.OPEN, SONG) + screen.project.open() + screen.expect( + screen.sequencer.voices.names, [SONG_SAMPLE, SONG_INSTRUMENT].__eq__, description="the song's voices" + ) + retitle_project(screen, RETITLED) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + def exit_asks_about_the_project(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project_prompt.is_shown, bool, description="the question about the project") + assert project_prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + + def exit_on_the_project_asks_about_the_reconstruction(screen: Screen) -> None: + tab(screen, 2) + enter(screen) + + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + assert reconstruction_prompt.words() == screen.words(EXIT_RECONSTRUCTION_MESSAGE) + assert not project_prompt.is_shown() + + def escape_keeps_the_application_and_both_changes(screen: Screen) -> None: + escape(screen) + + screen.expect(reconstruction_prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert screen.shown_windows() == () + assert screen.title() == titled( + screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True) + ) + assert saved_project_title(SONG) != RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_keys_save_the_project_and_leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(project_prompt.is_shown, bool, description="the question about the project again") + tab(screen, 1) + enter(screen) + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + + tab(screen, 2) + enter(screen) + + assert screen.wait_for_exit() + assert saved_project_title(SONG) == RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + open_the_project_and_change_both, + exit_asks_about_the_project, + exit_on_the_project_asks_about_the_reconstruction, + escape_keeps_the_application_and_both_changes, + the_keys_save_the_project_and_leave, + ).run() diff --git a/tests/screens/prompts/keyboard/test_load_chain.py b/tests/screens/prompts/keyboard/test_load_chain.py new file mode 100644 index 000000000..6043605d7 --- /dev/null +++ b/tests/screens/prompts/keyboard/test_load_chain.py @@ -0,0 +1,83 @@ +import operator +from typing import List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.reconstructions import ( + converted, + edited_title, + expect_open, + first_level_raised, + leading, + raise_the_first_level, + stored_levels, +) +from tests.suite.screens.worlds.recordings import BASS, LEAD, OPEN_RECONSTRUCTION + + +class TestTheLoadChainFromTheKeyboard: + """The keys answer Load at a run's end, and the question about unsaved changes that Load opens. + + The scenario edits the open reconstruction and converts a recording. Tab and Enter choose Load, which + asks about the changes. Escape keeps the edited reconstruction open. After a second conversion, Tab and + Enter choose Save, which writes the edit and opens the conversion. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_keeps_the_edits_and_the_keys_then_save_and_load(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = screen.reconstructions.unsaved_prompt + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + + def load_from_the_keyboard(screen: Screen, recording: str) -> None: + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert not converter.end_prompt.is_shown() + + def escape_keeps_the_edited_one(screen: Screen) -> None: + load_from_the_keyboard(screen, BASS) + + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen) + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def save_and_load_from_the_keyboard(screen: Screen) -> None: + load_from_the_keyboard(screen, LEAD) + + tab(screen, 1) + enter(screen) + + expect_open(screen, converted(home_path(LEAD))) + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + escape_keeps_the_edited_one, + save_and_load_from_the_keyboard, + ).run() diff --git a/tests/screens/prompts/keyboard/test_save_that_asks_where.py b/tests/screens/prompts/keyboard/test_save_that_asks_where.py new file mode 100644 index 000000000..e8c798a1b --- /dev/null +++ b/tests/screens/prompts/keyboard/test_save_that_asks_where.py @@ -0,0 +1,83 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.prompts.keyboard.constants import SETTLING_FRAMES +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import edited_title, expect_open, remove_from_the_browser, titled +from tests.suite.screens.vocabulary.dialogs import CLOSE_MESSAGE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" + + +class TestASaveThatAsksWhereFromTheKeyboard: + """The question a dismissed save dialog brings back answers to the keys like the first one. + + The open reconstruction loses its file in the browser, so closing it asks and Save asks where to write. + The save dialog is dismissed and the question returns. Escape keeps the reconstruction open. Closing + again, the keys choose Save, the save dialog names a file, and the reconstruction is written there and + closed. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_returned_question_then_the_keys_save_where_asked(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def take_its_file_away(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect(screen.title, edited_title(screen).__eq__, description="the reconstruction left unsaved") + + def save_asks_where_and_a_dismissed_dialog_asks_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.words() == screen.words(CLOSE_MESSAGE) + screen.answer_next_dialog(DialogKind.SAVE, None) + + tab(screen, 1) + enter(screen) + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + assert len(prompt.shown_windows()) == 1 + + def escape_keeps_it_open(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.title() == edited_title(screen) + assert screen.shown_windows() == () + + def the_keys_save_it_where_asked_and_close_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + tab(screen, 1) + enter(screen) + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + assert not prompt.is_shown() + + screen.scenario( + take_its_file_away, + save_asks_where_and_a_dismissed_dialog_asks_again, + escape_keeps_it_open, + the_keys_save_it_where_asked_and_close_it, + ).run() diff --git a/tests/screens/prompts/load/__init__.py b/tests/screens/prompts/load/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/load/test_conversion_over_edits.py b/tests/screens/prompts/load/test_conversion_over_edits.py new file mode 100644 index 000000000..9e61502eb --- /dev/null +++ b/tests/screens/prompts/load/test_conversion_over_edits.py @@ -0,0 +1,282 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.reconstructions import StoredReconstruction +from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, convert_alone, home_path +from tests.suite.screens.steps.reconstructions import ( + converted, + edited_title, + expect_open, + first_level_raised, + leading, + load_from_the_browser, + marked, + raise_the_first_level, + stored_levels, + titled, +) +from tests.suite.screens.vocabulary.dialogs import CANCEL, DISCARD, LOAD_MESSAGE, LOAD_TITLE, SAVE +from tests.suite.screens.worlds.recordings import BASS, LEAD, OPEN_RECONSTRUCTION + +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +REPLACED_MESSAGE: Final[str] = "global.dialog.message.load_replaced_reconstruction" + + +def convert_and_ask_to_load(screen: Screen, recording: str) -> None: + """Converts the home's ``recording`` alone on Pulse 1, leaving a volume to edit. + + Answers Load at the run's end. + """ + converter = screen.main.converter + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + + converter.end_prompt.confirm() + + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end answered") + + +def load_question(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + """The question about unsaved changes a load asks: its title, its words and its answers.""" + return ( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + + +def asked(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + """Waits for the question about unsaved changes and returns its title, words and answers.""" + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + return prompt.title(), prompt.words(), prompt.answers() + + +class TestLoadingAConversionOverAnEditedReconstruction: + """Load at a run's end, with an edited reconstruction open, asks about its changes first. + + Save writes them and loads the conversion, Discard loads it and leaves the file as it was, and + Cancel keeps the edited reconstruction open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_cancel_keeps_the_edits_and_save_writes_them_before_loading(self, screen: Screen) -> None: + """Cancel keeps the edits and the file as they were. + + A second Load then saves the edit and opens the conversion. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + stored: List[bytes] = [] + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + typed.append(raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen))) + + def load_asks_about_the_changes(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + + assert asked(screen) == load_question(screen) + + def cancel_keeps_the_edited_one_open(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen) + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def save_writes_the_changes_and_loads_the_next(screen: Screen) -> None: + convert_and_ask_to_load(screen, LEAD) + assert asked(screen) == load_question(screen) + + prompt.save() + + expect_open(screen, converted(home_path(LEAD))) + assert screen.title() == titled(screen, converted(home_path(LEAD)).name) + assert not prompt.is_shown() + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + load_asks_about_the_changes, + cancel_keeps_the_edited_one_open, + save_writes_the_changes_and_loads_the_next, + ).run() + + def test_discard_loads_the_conversion_and_leaves_the_file(self, screen: Screen) -> None: + """Discard opens the conversion and keeps the file as it was. + + A later save of the reloaded file writes only the new edit. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + stored: List[bytes] = [] + standing: Dict[ChannelName, List[int]] = {} + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + standing.update({channel: stored_levels(OPEN_RECONSTRUCTION, channel) for channel in PULSES}) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + + def discard_loads_the_conversion(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + assert asked(screen) == load_question(screen) + + prompt.confirm() + + expect_open(screen, converted(home_path(BASS))) + assert screen.title() == titled(screen, converted(home_path(BASS)).name) + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_file_takes_a_save_made_later(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level(screen, ChannelName.PULSE2, title=edited_title(screen)) + + reconstructions.save_from_menu() + + screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") + second = standing[ChannelName.PULSE2] + assert leading(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE2), second) == first_level_raised(second) + assert stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) == standing[ChannelName.PULSE1] + + screen.scenario( + edit_the_open_reconstruction, + discard_loads_the_conversion, + the_file_takes_a_save_made_later, + ).run() + + +class TestAConversionWrittenOverTheOpenFile: + """A run writing over the edited reconstruction open offers Discard and Cancel only. + + The scenario converts a recording and edits the loaded result. A second run replaces that file and + its end asks about it. Cancel keeps the edits, and Discard loads what the run wrote. + """ + + def test_only_discard_and_cancel_and_each_does_what_it_says(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + converter = screen.main.converter + prompt = reconstructions.replaced_prompt + drawn: Dict[str, str] = {} + + def convert_and_load(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + + expect_open(screen, converted(home_path(BASS))) + drawn["converted"] = screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + bool, + description="Pulse 1's volume drawn", + ) + + def edit_it(screen: Screen) -> None: + drawn["edit"] = raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)), + ) + + def convert_over_it(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + converter.press_action() + screen.expect(converter.overwrite_prompt.is_shown, bool, description="the question about replacing") + converter.overwrite_prompt.confirm() + screen.bridge.expect( + converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS + ) + + converter.end_prompt.confirm() + + screen.expect(prompt.is_shown, bool, description="the question about the file written over") + assert prompt.title() == screen.words(LOAD_TITLE) + assert prompt.words() == screen.words(REPLACED_MESSAGE) + assert prompt.answers() == (screen.words(DISCARD), screen.words(CANCEL)) + assert not reconstructions.unsaved_prompt.is_shown() + + def cancel_keeps_the_edits(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["edit"] + + def discard_loads_what_the_run_wrote(screen: Screen) -> None: + convert_over_it(screen) + + prompt.confirm() + + screen.expect( + screen.title, + titled(screen, converted(home_path(BASS)).name).__eq__, + description="the conversion loaded", + ) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["converted"] + + screen.scenario( + convert_and_load, + edit_it, + convert_over_it, + cancel_keeps_the_edits, + discard_loads_what_the_run_wrote, + ).run() + + +class TestReloadingACleanReconstruction: + """A reconstruction with every change saved loads its own file again at once, as the file now stands.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_file_changed_on_disk_loads_as_it_stands(self, screen: Screen) -> None: + """After the file is rewritten on disk, loading it from the browser shows the rewritten content.""" + reconstructions = screen.reconstructions + standing: List[str] = [] + + def change_the_file_behind_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + reconstructions.save_from_menu() + screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") + + StoredReconstruction(OPEN_RECONSTRUCTION).write() + + def reloading_asks_nothing(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + standing[0].__eq__, + description="the file as it now stands", + ) + assert not reconstructions.unsaved_prompt.is_shown() + assert screen.title() == titled(screen, OPEN_RECONSTRUCTION.name) + + screen.scenario(change_the_file_behind_it, reloading_asks_nothing).run() diff --git a/tests/screens/prompts/modals/__init__.py b/tests/screens/prompts/modals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/modals/constants.py b/tests/screens/prompts/modals/constants.py new file mode 100644 index 000000000..256e347f9 --- /dev/null +++ b/tests/screens/prompts/modals/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 30 diff --git a/tests/screens/prompts/modals/test_held_rebuild.py b/tests/screens/prompts/modals/test_held_rebuild.py new file mode 100644 index 000000000..1d2fea476 --- /dev/null +++ b/tests/screens/prompts/modals/test_held_rebuild.py @@ -0,0 +1,187 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + load_from_the_browser, + marked, + raise_the_first_level_while_held, + remove_from_the_browser, + titled, +) +from tests.suite.screens.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION + +REBUILD_FAILURE: Final[str] = "The rebuild broke on its worker" + + +class RebuildBrokeError(RuntimeError): + """The failure a held rebuild is let go with, standing for a rebuild that raised.""" + + +class TestTwoGesturesWaitingOnAHeldRebuild: + """A load and a close asked for while an edit is on its way each ask their question once it lands, in + order. + + A first level is raised while the rebuild is held, then a load and a close are asked for. Both wait; + when the rebuild is released the load asks first, Cancel brings the close's question, and Cancel again + leaves the edited reconstruction open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_their_questions_come_one_after_the_other( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """The load asks first, the close asks next, and then the screen is quiet.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + def load_and_close_while_it_is_held(screen: Screen) -> None: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def the_load_asks_first(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the first question") + assert prompt.title() == screen.words(LOAD_TITLE) + assert len(screen.shown_windows()) == 1 + + def the_close_asks_next(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.title, screen.words(CLOSE_TITLE).__eq__, description="the second question") + assert len(screen.shown_windows()) == 1 + + def nothing_asks_after_them(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the questions answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_while_the_rebuild_is_held, + load_and_close_while_it_is_held, + the_load_asks_first, + the_close_asks_next, + nothing_asks_after_them, + leave_letting_it_go, + ).run() + + +class TestAFailedRebuildBeforeAClose: + """A rebuild that fails while a close waits on it reports the failure first, and the close asks after. + + The open reconstruction loses its file, its first level is raised while the rebuild is held, and a + close is asked for. The rebuild fails; the failure shows alone, and once it is dismissed the close + asks. Cancel keeps the reconstruction as it stood. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_first_and_the_question_after( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """The failure shows first and the close asks once the failure is dismissed.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + standing: List[str] = [] + + def edit_a_removed_reconstruction_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_while_it_is_held(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_failure_comes_first(screen: Screen) -> None: + regeneration_hold.fail(RebuildBrokeError(REBUILD_FAILURE)) + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(RebuildBrokeError.__name__) + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def the_question_comes_once_the_failure_is_read(screen: Screen) -> None: + notice.dismiss() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + + def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_a_removed_reconstruction_while_the_rebuild_is_held, + close_while_it_is_held, + the_failure_comes_first, + the_question_comes_once_the_failure_is_read, + cancel_keeps_the_reconstruction_as_it_stood, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py new file mode 100644 index 000000000..f9f1005a3 --- /dev/null +++ b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py @@ -0,0 +1,270 @@ +import operator +from typing import Dict, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, gather, home_path +from tests.suite.screens.vocabulary.converter import CONVERT_ONE +from tests.suite.screens.vocabulary.settings import LISTENING +from tests.suite.screens.worlds.recordings import BASS + + +def start_a_held_run(screen: Screen) -> None: + """Converts the home's bass on the Main tab; the hold keeps the run halfway and the function returns once + progress shows. + """ + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + + +def let_the_run_end(screen: Screen, hold: ConversionHold) -> None: + """Releases the held run and waits until it is over, while its question waits for its turn.""" + converter = screen.main.converter + hold.release() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.frames(SETTLING_FRAMES) + + +def the_end_comes_and_closes(screen: Screen) -> None: + """Expects the run's question to come once the screen is free, and Cancel puts it away.""" + end = screen.main.converter.end_prompt + screen.expect(end.is_shown, bool, description="the run's question") + assert len(screen.shown_windows()) == 1 + + end.cancel() + + screen.expect(end.is_shown, operator.not_, description="the run's question answered") + assert screen.shown_windows() == () + + +class TestARunEndingBehindDisplaySettings: + """A run that ends while Display settings is open asks its question once the dialog is answered.""" + + def test_the_question_waits_for_an_unchanged_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """With Display settings left unchanged, the run's question comes after Cancel closes the dialog.""" + settings = screen.display_settings + + def open_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + + settings.open() + + screen.expect(settings.is_shown, bool, description="Display settings") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + open_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_hands_the_screen_over, + ).run() + + def test_the_discard_question_comes_before_the_runs(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """With a changed setting, Cancel asks about discarding the change first, and the run's question comes + after Discard. + """ + settings = screen.display_settings + discard = settings.discard_prompt + + def change_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original = settings.vsync() + + settings.toggle_vsync() + + screen.expect(settings.vsync, original.__ne__, description="the box flipped") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_asks_about_the_change_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(discard.is_shown, bool, description="the discard question") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def discard_hands_the_screen_over(screen: Screen) -> None: + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + assert not settings.is_shown() + the_end_comes_and_closes(screen) + + screen.scenario( + change_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_asks_about_the_change_first, + discard_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheWindowModeCountdown: + """A run that ends while the window-mode countdown shows waits for the countdown and for the dialog behind + it. + """ + + def test_the_question_waits_for_keep_and_the_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Keep brings the dialog back, and the run's question comes after the change is discarded.""" + settings = screen.display_settings + discard = settings.discard_prompt + original: List[bool] = [] + + def change_the_window_mode_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original.append(settings.borderless()) + + settings.toggle_borderless() + + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def keep_brings_the_dialog_back_first(screen: Screen) -> None: + settings.keep() + + screen.expect(settings.is_shown, bool, description="Display settings back") + screen.frames(SETTLING_FRAMES) + assert not settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + assert settings.borderless() != original[0] + + def discarding_the_change_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + screen.expect(discard.is_shown, bool, description="the discard question") + + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + the_end_comes_and_closes(screen) + + screen.scenario( + change_the_window_mode_over_a_held_run, + the_run_ends_and_its_question_waits, + keep_brings_the_dialog_back_first, + discarding_the_change_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheReassignQuestion: + """A run that ends while Keyboard settings asks to reassign keys waits for the question and for the dialog + behind it. + """ + + def test_the_question_waits_for_both(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Cancel brings the dialog back, and the run's question comes after the dialog closes.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + keys: Dict[ShortcutId, str] = {} + + def ask_to_reassign_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + keys.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert reassign.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_brings_the_dialog_back_first(screen: Screen) -> None: + reassign.cancel() + + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] + + def closing_the_dialog_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + ask_to_reassign_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_brings_the_dialog_back_first, + closing_the_dialog_hands_the_screen_over, + ).run() + + +class TestTheRowListeningAfterTheReassignQuestion: + """A row that reads as listening after Cancel answers the reassign question takes the next keys pressed. + + Redo listens, Undo's keys ask to reassign, and Cancel brings the dialog back with the row still + listening. The same keys then ask again. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a row listening again after the reassign question takes no keys", + ) + def test_the_same_keys_ask_again(self, screen: Screen) -> None: + """The same keys pressed again ask the reassign question once more.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.REDO) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning again") diff --git a/tests/screens/prompts/modals/test_save_into_a_closed_folder.py b/tests/screens/prompts/modals/test_save_into_a_closed_folder.py new file mode 100644 index 000000000..babcf86a5 --- /dev/null +++ b/tests/screens/prompts/modals/test_save_into_a_closed_folder.py @@ -0,0 +1,93 @@ +import operator +import stat +from typing import Final + +import pytest + +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open, marked, remove_from_the_browser, titled +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" +LOCKED_FOLDER: Final[str] = "Locked" +READ_AND_ENTER: Final[int] = stat.S_IRUSR | stat.S_IXUSR +SAVE_FAILED: Final[str] = "global.dialog.message.reconstruction_save_failed" + + +class TestASaveIntoAFolderThatTakesNothing: + """A save into a folder that accepts no file shows its failure alone, and the reconstruction stays open. + + The open reconstruction loses its file and a read-only folder is made. Close asks, Save into the folder + fails, and the failure shows alone. Dismissing it keeps the reconstruction open, and a save into a + writable folder closes it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_stands_alone_and_a_save_elsewhere_closes_it(self, screen: Screen) -> None: + """The failure shows alone, and a save elsewhere writes the file and closes the reconstruction.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + locked = RECONSTRUCTIONS_DIRECTORY / LOCKED_FOLDER + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + locked.mkdir() + locked.chmod(READ_AND_ENTER) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + + def save_into_the_locked_folder(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing") + screen.answer_next_dialog(DialogKind.SAVE, locked / KEPT) + + prompt.save() + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(PermissionError.__name__) + assert screen.words(SAVE_FAILED) in notice.words() + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def dismissing_it_leaves_the_reconstruction_open(screen: Screen) -> None: + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the failure dismissed") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert not (locked / KEPT).exists() + + def a_save_elsewhere_closes_it(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + + screen.scenario( + remove_its_file, + save_into_the_locked_folder, + dismissing_it_leaves_the_reconstruction_open, + a_save_elsewhere_closes_it, + ).run() diff --git a/tests/screens/prompts/open_voice/__init__.py b/tests/screens/prompts/open_voice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/open_voice/test_open_voice.py b/tests/screens/prompts/open_voice/test_open_voice.py new file mode 100644 index 000000000..80e3ec6cc --- /dev/null +++ b/tests/screens/prompts/open_voice/test_open_voice.py @@ -0,0 +1,283 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + edit_envelope, + expect_open, + marked, + raise_the_first_level, + titled, + voice_title, +) +from tests.suite.screens.steps.sequencer import double_click_voice, open_voice, voice_row +from tests.suite.screens.vocabulary.dialogs import CANCEL, DISCARD, EDIT_MESSAGE, EDIT_TITLE, SAVE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +SAMPLE_ORDINAL: Final[int] = 0 +PERSONS_HOLD_FRAMES: Final[int] = 10 +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +REMOVE_VOICE: Final[str] = "sequencer.voices.label.context_remove" +FADING: Final[str] = "12 8 4" + + +def sample_title(screen: Screen, *, unsaved: bool) -> str: + """The window title while the project's sample is open, with or without unsaved changes.""" + return voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=unsaved) + + +def leave_without_saving(screen: Screen) -> None: + """Exits and discards the project's changes at the question the exit asks.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def open_the_sample(screen: Screen) -> None: + """Opens the project's sample from the Sequencer and waits for it to show in front.""" + open_voice(screen, SONG_SAMPLE) + + screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + + +class TestAVoiceDoubleClickedOverAnEditedReconstruction: + """A voice double-clicked over an edited reconstruction of its own asks about the edits first. + + The scenario edits the open reconstruction and double-clicks a sample. Cancel keeps the + reconstruction and the Sequencer in front. After a second double-click, Discard opens the sample + and keeps the reconstruction's file as it was. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open beside the song.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_question_comes_first_and_cancel_keeps_the_reconstruction(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + edited = titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + stored: List[bytes] = [] + + def edit_the_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited) + + def the_double_click_asks_first(screen: Screen) -> None: + double_click_voice(screen, SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert prompt.title() == screen.words(EDIT_TITLE) + assert prompt.words() == screen.words(EDIT_MESSAGE) + assert prompt.answers() == (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)) + assert screen.title() == edited + + def cancel_keeps_the_reconstruction(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + assert screen.tabs.front() is Tab.SEQUENCER + + def discard_opens_the_voice(screen: Screen) -> None: + double_click_voice(screen, SONG_SAMPLE) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + edit_the_reconstruction, + the_double_click_asks_first, + cancel_keeps_the_reconstruction, + discard_opens_the_voice, + ).run() + + +class TestTheOpenVoiceGoesWithItsProject: + """The Reconstructions tab empties once the voice open on it leaves the project, or the project closes.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_removing_the_open_voice_empties_the_tab(self, screen: Screen) -> None: + """Removing the open sample from the voices list leaves the project alone in the title. + + The file line of the Reconstructions tab ends empty. + """ + voices = screen.sequencer.voices + menu = screen.context_menu + + def remove_it(screen: Screen) -> None: + open_the_sample(screen) + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(voice_row(screen, SONG_SAMPLE)) + screen.expect(menu.is_shown, bool, description="the voice's menu") + + menu.choose(screen.words(REMOVE_VOICE)) + + screen.expect(voices.names, [SONG_INSTRUMENT].__eq__, description="the sample gone") + assert not voices.remove_prompt.is_shown() + + def the_tab_empties(screen: Screen) -> None: + screen.expect( + screen.title, + titled(screen, marked(SONG.stem, unsaved=True)).__eq__, + description="nothing open beside the project", + ) + assert screen.reconstructions.file_line() == "" + + screen.scenario(remove_it, the_tab_empties, leave_without_saving).run() + + def test_closing_the_project_empties_the_tab(self, screen: Screen) -> None: + """Closing the project leaves nothing open, no voices and an empty file line.""" + + def close_the_project(screen: Screen) -> None: + open_the_sample(screen) + + screen.project.close() + + screen.expect(screen.title, titled(screen).__eq__, description="nothing open") + assert screen.reconstructions.file_line() == "" + assert screen.sequencer.voices.names() == [] + + screen.scenario(close_the_project).run() + + +class TestTheTabFollowsTheProjectsHistory: + """Undo and redo pressed on the Sequencer tab redraw the voice open on the Reconstructions tab.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_undo_and_redo_of_a_sample_edit(self, screen: Screen) -> None: + """Undo restores the sample's volume as it stood. + + Redo brings the edit back with its unsaved title. + """ + reconstructions = screen.reconstructions + drawn: Dict[str, str] = {} + + def edit_the_sample(screen: Screen) -> None: + open_the_sample(screen) + drawn["standing"] = reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + drawn["edit"] = raise_the_first_level(screen, ChannelName.PULSE1, title=sample_title(screen, unsaved=True)) + + def undo_from_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["standing"].__eq__, + description="the edit undone", + ) + + def redo_from_the_sequencer(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.REDO) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["edit"].__eq__, + description="the edit redone", + ) + assert screen.title() == sample_title(screen, unsaved=True) + + screen.scenario(edit_the_sample, undo_from_the_sequencer, redo_from_the_sequencer, leave_without_saving).run() + + def test_undo_of_an_instrument_edit_redraws_its_envelopes(self, screen: Screen) -> None: + """Undo restores the instrument's volume as it stood and the project's title shows no unsaved mark.""" + reconstructions = screen.reconstructions + drawn: Dict[str, str] = {} + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the instrument open") + drawn["standing"] = reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=titled(screen, marked(SONG.stem, unsaved=True)), + ) + + def undo_from_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["standing"].__eq__, + description="the edit undone", + ) + assert screen.title() == titled(screen, SONG.stem) + + screen.scenario(edit_the_instrument, undo_from_the_sequencer).run() + + +class TestAVoiceOpenedByADoubleClickHeldAsAPersonHoldsIt: + """A voice double-clicked opens as it stands, however long the second press is held.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a voice double-clicked with the second press held opens edited", + ) + def test_the_voice_opens_unchanged(self, screen: Screen) -> None: + """The volume drawn after a held double-click equals the volume drawn after a plain open. + + The title shows the sample without an unsaved mark. + """ + reconstructions = screen.reconstructions + drawn: List[Tuple[str, ...]] = [] + + def read_it_and_put_it_away(screen: Screen) -> None: + open_the_sample(screen) + drawn.append(tuple(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME) for channel in PULSES)) + + reconstructions.close_from_menu() + + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the sample put away") + + def double_click_it_holding_the_second_press(screen: Screen) -> None: + screen.hand.double_click_held(voice_row(screen, SONG_SAMPLE), frames=PERSONS_HOLD_FRAMES) + + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + screen.frames(PERSONS_HOLD_FRAMES) + assert screen.title() == sample_title(screen, unsaved=False) + assert ( + tuple(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME) for channel in PULSES) + == drawn[0] + ) + + screen.scenario(read_it_and_put_it_away, double_click_it_holding_the_second_press).run() diff --git a/tests/screens/prompts/vanished/__init__.py b/tests/screens/prompts/vanished/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/vanished/test_reconstruction_removed.py b/tests/screens/prompts/vanished/test_reconstruction_removed.py new file mode 100644 index 000000000..f3c3c0345 --- /dev/null +++ b/tests/screens/prompts/vanished/test_reconstruction_removed.py @@ -0,0 +1,130 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + edited_title, + expect_open, + remove_from_the_browser, + stored_levels, + titled, +) +from tests.suite.screens.vocabulary.dialogs import CANCEL, CLOSE, CLOSE_MESSAGE, CLOSE_TITLE, SAVE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +SETTLING_FRAMES: Final[int] = 10 +NOT_APPLICABLE: Final[str] = "reconstructions.reconstruction.label.path_not_applicable" +RATE_SET_BY_THE_PROJECT: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_locked" + + +def take_its_file_away(screen: Screen) -> None: + """Removes the open reconstruction's file in the browser and waits for it to show as unsaved.""" + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect(screen.title, edited_title(screen).__eq__, description="the reconstruction left unsaved") + + +class TestAReconstructionWhoseFileWasRemoved: + """The open reconstruction whose file the browser removed stays open with nowhere to save, and asks where. + + Closing it asks first. Save asks where to write it, a dismissed dialog brings the question back + once, and Cancel keeps it open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_close_asks_where_a_dismissed_dialog_asks_once_and_cancel_keeps_it(self, screen: Screen) -> None: + """The last Save names a file, which receives the reconstruction's levels. + + The removed file stays gone. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + standing: List[List[int]] = [] + + def remove_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, channel) for channel in PULSES) + + take_its_file_away(screen) + + assert reconstructions.file_line() == screen.words(NOT_APPLICABLE) + assert not reconstructions.can_save() + + def close_asks_first(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + assert prompt.words() == screen.words(CLOSE_MESSAGE) + assert prompt.answers() == (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)) + + def save_asks_where_and_a_dismissed_dialog_asks_once_more(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, None) + + prompt.save() + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + screen.frames(SETTLING_FRAMES) + assert len(prompt.shown_windows()) == 1 + (request,) = screen.dialog_requests() + assert request.kind is DialogKind.SAVE + assert request.initial_directory == RECONSTRUCTIONS_DIRECTORY + assert request.suggested_name == OPEN_RECONSTRUCTION.name + + def cancel_keeps_it_open(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == edited_title(screen) + assert len(screen.dialog_requests()) == 1 + + def save_where_asked_writes_it_and_closes(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert [stored_levels(kept, channel) for channel in PULSES] == standing + assert not OPEN_RECONSTRUCTION.exists() + + screen.scenario( + remove_it, + close_asks_first, + save_asks_where_and_a_dismissed_dialog_asks_once_more, + cancel_keeps_it_open, + save_where_asked_writes_it_and_closes, + ).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a reconstruction whose file was removed reads as a sample of the project", + ) + def test_the_locked_rate_says_nothing_of_a_project(self, screen: Screen) -> None: + """The rate stays locked and its explanation words speak of the reconstruction, not of a project.""" + reconstructions = screen.reconstructions + take_its_file_away(screen) + + assert not reconstructions.can_retune() + assert reconstructions.retune_lock_explained() + assert reconstructions.retune_lock_words() != screen.words(RATE_SET_BY_THE_PROJECT) diff --git a/tests/screens/reconstructions/__init__.py b/tests/screens/reconstructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/conftest.py b/tests/screens/reconstructions/conftest.py new file mode 100644 index 000000000..afb21489d --- /dev/null +++ b/tests/screens/reconstructions/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import playing_world + + +@pytest.fixture +def world() -> World: + """The home every Reconstructions scenario starts in: reconstructions that play, and a project.""" + return playing_world() diff --git a/tests/screens/reconstructions/edits/__init__.py b/tests/screens/reconstructions/edits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/edits/test_gestures_at_once.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py new file mode 100644 index 000000000..5753167e6 --- /dev/null +++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py @@ -0,0 +1,303 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import save_project_as +from tests.suite.screens.steps.reconstructions import ( + expect_open, + marked, + raise_the_first_level, + stored_levels, + titled, + voice_title, +) +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.views.bar_graph import BarGraph +from tests.suite.screens.views.instruments import Instruments +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION, SONG, SONG_SAMPLE, STEMS_RECONSTRUCTION + +DRAGGED_ITEM: Final[int] = 5 +FIRST_ITEM: Final[int] = 1 +QUIET: Final[float] = 4.0 +LOUDEST: Final[int] = 15 +ARPEGGIO_STEP: Final[float] = 12.0 +ARPEGGIO_FLOOR: Final[int] = -128 +ARPEGGIO_CEILING: Final[int] = 127 +SAMPLE_ORDINAL: Final[int] = 0 +LAST_RECORDING: Final[str] = "2" +SETTLE_FRAMES: Final[int] = 20 +NEAR_THE_START: Final[float] = 0.05 +ZOOM_NOTCHES: Final[int] = 5 +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" + + +def items(instruments: Instruments, channel: ChannelName, feature: FeatureKey) -> List[int]: + """The items of the envelope that ``channel`` draws for ``feature``, read from the instruments panel.""" + return [int(item) for item in instruments.envelope(channel, feature).split()] + + +def dragged( + graph: BarGraph, + standing: List[int], + *, + end: float, + bounds: Tuple[int, int], +) -> Tuple[int, List[int]]: + """Drags the bar of the item :data:`DRAGGED_ITEM` to ``end`` and returns the item it moved and the items + expected after. + + The item the plot puts under the release takes the value there, brought within ``bounds``, and every other + item stands as it did. + """ + release: Point = graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=end) + index, value = graph.item_under(release) + low, high = bounds + expected = [*standing] + expected[index] = min(max(round(value), low), high) + return index, expected + + +def expect_items( + screen: Screen, instruments: Instruments, channel: ChannelName, feature: FeatureKey, expected: List[int] +) -> None: + """Waits until the envelope of ``channel`` and ``feature`` reads ``expected``.""" + screen.expect( + lambda: items(instruments, channel, feature), + expected.__eq__, + description=f"{channel} {feature} as the edits left it", + ) + + +def leave_letting_it_go(screen: Screen) -> None: + """Presses Exit, confirms the question about unsaved changes and waits for the application to close.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestADragAndARemovalAtOnce: + """A volume dragged and a recording removed while the drag is on its way both land. + + The rebuild is held while a volume item is dragged; a recording is removed and the removal confirmed + meanwhile. After the rebuild is released, the recording is gone and the dragged value stands. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_both_land(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The removed recording leaves the card and the dragged volume item keeps its new value.""" + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + stems = reconstructions.stems + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] + + def drag_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + + moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def remove_a_recording_meanwhile(screen: Screen) -> None: + stems.remove(LAST_RECORDING) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + + stems.remove_prompt.confirm() + + screen.expect(stems.remove_prompt.is_shown, operator.not_, description="the question answered") + assert stems.has_row(LAST_RECORDING) + + def both_land(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(lambda: stems.has_row(LAST_RECORDING), operator.not_, description="the recording gone") + index, expected = moved[0] + screen.expect( + lambda: items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1], + expected[: index + 1].__eq__, + description="the drag landed", + ) + + screen.scenario( + drag_while_the_rebuild_is_held, remove_a_recording_meanwhile, both_land, leave_letting_it_go + ).run() + + +class TestADragAndAnUndoAtOnce: + """Undo pressed while a drag of a sample is on its way undoes the drag, and the edit saved before it stays. + + Consecutive edits of one sample run together into one entry of the history, and a save closes the run, so + the edit before the drag stands in an entry of its own. + + A sample opens from the song, its first level is raised and the project is saved. With the rebuild held, + an item is dragged and Undo pressed. Once released, the envelope reads as saved and holds there. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A project is open at start, with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_drag_is_undone(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The envelope returns to the values of the saved edit and stays there.""" + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + edited: List[List[int]] = [] + + def edit_the_sample_and_save(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + screen.expect( + screen.title, + voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False).__eq__, + description="the sample open", + ) + regeneration_hold.release() + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True), + ) + edited.append(items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)) + + save_project_as(screen, SAVED_SONG) + + def drag_and_undo_while_the_rebuild_is_held(screen: Screen) -> None: + regeneration_hold.hold_again() + screen.hand.scroll_into_view(graph.plot) + release = graph.drag_item(FIRST_ITEM, start=edited[0][FIRST_ITEM], end=QUIET) + index, _ = graph.item_under(release) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index] + + screen.press_shortcut(ShortcutId.UNDO) + + def the_drag_alone_is_undone(screen: Screen) -> None: + regeneration_hold.release() + + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, edited[0]) + screen.frames(SETTLE_FRAMES) + assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0] + + screen.scenario( + edit_the_sample_and_save, drag_and_undo_while_the_rebuild_is_held, the_drag_alone_is_undone + ).run() + + +class TestADragAndASaveAtOnce: + """A save asked for while a drag is on its way writes the drag. + + With the rebuild held, an item is dragged and Save pressed; the file stays as it was until the rebuild is + released, then holds the dragged levels and the title shows a saved file. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_the_file_has_the_drag(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The saved file holds the dragged levels.""" + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] + stored: List[bytes] = [] + + def drag_and_save_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + stored.append(SHORT_RECONSTRUCTION.read_bytes()) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + screen.press_shortcut(ShortcutId.SAVE_RECONSTRUCTION) + + screen.frames(SETTLE_FRAMES) + assert SHORT_RECONSTRUCTION.read_bytes() == stored[0] + + def the_save_writes_the_drag(screen: Screen) -> None: + regeneration_hold.release() + + index, expected = moved[0] + screen.expect( + lambda: stored_levels(SHORT_RECONSTRUCTION, ChannelName.PULSE1)[: index + 1], + expected[: index + 1].__eq__, + description="the file holding the drag", + ) + screen.frames(SETTLE_FRAMES) + assert screen.title() == titled(screen, SHORT_RECONSTRUCTION.name) + + screen.scenario(drag_and_save_while_the_rebuild_is_held, the_save_writes_the_drag).run() + + +class TestTwoDimensionsBeforeTheFadeEnds: + """A volume and an arpeggio dragged one after the other while the first is on its way both stay. + + With the rebuild held, a volume item is dragged and then an arpeggio item. Once released, both envelopes + hold their dragged values and the title shows unsaved changes. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_both_edits_stay(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The volume and the arpeggio envelopes both read their dragged values.""" + instruments = screen.reconstructions.instruments + volume = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + arpeggio = instruments.graph(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + moved: List[Tuple[int, List[int]]] = [] + + def drag_the_volume_while_held(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(volume.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + + moved.append(dragged(volume, standing, end=QUIET, bounds=(0, LOUDEST))) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def drag_the_arpeggio_meanwhile(screen: Screen) -> None: + screen.hand.scroll_into_view(arpeggio.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + moved.append(dragged(arpeggio, standing, end=ARPEGGIO_STEP, bounds=(ARPEGGIO_FLOOR, ARPEGGIO_CEILING))) + + def both_stay(screen: Screen) -> None: + regeneration_hold.release() + + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, moved[0][1]) + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO, moved[1][1]) + assert screen.title() == titled(screen, marked(SHORT_RECONSTRUCTION.name, unsaved=True)) + + screen.scenario( + drag_the_volume_while_held, + drag_the_arpeggio_meanwhile, + both_stay, + leave_letting_it_go, + ).run() diff --git a/tests/screens/reconstructions/instruments/__init__.py b/tests/screens/reconstructions/instruments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/instruments/constants.py b/tests/screens/reconstructions/instruments/constants.py new file mode 100644 index 000000000..f20c13e30 --- /dev/null +++ b/tests/screens/reconstructions/instruments/constants.py @@ -0,0 +1,9 @@ +from typing import Final + +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A + +STILL_FRAMES: Final[int] = 20 +NOTE_FRAMES: Final[int] = 30 +FADING: Final[str] = "12 8 4" +NOTHING: Final[str] = "" +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") diff --git a/tests/screens/reconstructions/instruments/steps.py b/tests/screens/reconstructions/instruments/steps.py new file mode 100644 index 000000000..39c40a81b --- /dev/null +++ b/tests/screens/reconstructions/instruments/steps.py @@ -0,0 +1,33 @@ +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import FADING +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import edit_envelope, marked, titled +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.worlds.recordings import SONG, SONG_INSTRUMENT + + +def open_the_instrument(screen: Screen) -> None: + """Opens the song's instrument from the Sequencer and waits for the Audition switch to appear.""" + instruments = screen.reconstructions.instruments + open_voice(screen, SONG_INSTRUMENT) + + screen.expect(instruments.offers_audition, bool, description="the instrument open") + + +def song_title(screen: Screen, *, unsaved: bool) -> str: + """The window title of the song, marked as unsaved or saved.""" + return titled(screen, marked(SONG.stem, unsaved=unsaved)) + + +def give_it_a_volume(screen: Screen) -> None: + """Opens the song's instrument and types the fading sequence into its Pulse 1 Volume field, leaving the + project unsaved. + """ + open_the_instrument(screen) + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) diff --git a/tests/screens/reconstructions/instruments/test_audition.py b/tests/screens/reconstructions/instruments/test_audition.py new file mode 100644 index 000000000..5d3abaecf --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_audition.py @@ -0,0 +1,93 @@ +from functools import partial +from typing import Callable, Dict, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import GeneratorName +from tests.screens.reconstructions.instruments.constants import NOTE_FRAMES, PIANO_C +from tests.screens.reconstructions.instruments.steps import give_it_a_volume +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.vocabulary.playback import PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT + + +class TestTheAuditionSwitch: + """The Audition switch draws the instrument on the generator it names, and back. + + The instrument gets a volume and shows on the pulse. Choosing the triangle redraws the waveform, + choosing the pulse again restores the first drawing, and the application exits letting the project + go. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_generator_redraws_the_waveform(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + waveform = screen.reconstructions.waveform + drawn: Dict[GeneratorName, List[float]] = {} + + def drawn_now() -> List[float]: + return waveform.drawn(SONG_INSTRUMENT) + + def on_the_pulse(screen: Screen) -> None: + give_it_a_volume(screen) + + assert instruments.audition() == screen.generator_words(GeneratorName.PULSE) + drawn[GeneratorName.PULSE] = drawn_now() + + def on_the_triangle(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.TRIANGLE)) + + drawn[GeneratorName.TRIANGLE] = screen.expect( + drawn_now, drawn[GeneratorName.PULSE].__ne__, description="the waveform redrawn" + ) + + def back_on_the_pulse(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.PULSE)) + + screen.expect(drawn_now, drawn[GeneratorName.PULSE].__eq__, description="the pulse drawn again") + + screen.scenario(on_the_pulse, on_the_triangle, back_on_the_pulse, leave_letting_the_project_go).run() + + +class TestAnInstrumentsWaveform: + """A click on an open instrument's waveform keeps playback silent, while a note key sounds it. + + A reconstruction that plays stands open before the instrument, so a click that played would play it. + The click plays nothing and leaves the Play button as it was; the note key plays sound. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song and a playable reconstruction.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=SONG) + + def test_a_click_plays_nothing_and_a_key_does(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + + def heard_over(gesture: Callable[[], None]) -> bool: + heard = screen.sound_heard() + gesture() + screen.frames(NOTE_FRAMES) + + return screen.sound_heard() > heard + + def a_click_plays_nothing(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + assert not heard_over(partial(waveform.click, 0.5)) + assert screen.sequencer.playback.play_entry() == screen.words(PLAY) + + def a_note_key_sounds_it(screen: Screen) -> None: + assert heard_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) + + screen.scenario(a_click_plays_nothing, a_note_key_sounds_it, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_note_keys.py b/tests/screens/reconstructions/instruments/test_note_keys.py new file mode 100644 index 000000000..2dcb69c89 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_note_keys.py @@ -0,0 +1,99 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import NOTE_FRAMES, NOTHING, PIANO_C +from tests.screens.reconstructions.instruments.steps import give_it_a_volume +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.keys import IMGUI_DIGIT_ZERO +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG + +PIANO_C_SHARP_UP: Final[int] = IMGUI_DIGIT_ZERO + 2 + + +class TestTheNoteKeys: + """With an instrument open the note keys sound it, a field being typed in takes them as characters, and + the keys a reconstruction gives a meaning keep it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_keys_sound_the_instrument_and_a_field_keeps_them(self, screen: Screen) -> None: + """Piano keys play sound; with the Arpeggio field focused, the same key writes its letter and plays + nothing. + """ + instruments = screen.reconstructions.instruments + + def sounded(key: int) -> bool: + heard = screen.sound_heard() + screen.hand.press_key(key, modifiers=[]) + screen.frames(NOTE_FRAMES) + + return screen.sound_heard() > heard + + def the_keys_sound_it(screen: Screen) -> None: + give_it_a_volume(screen) + + assert sounded(PIANO_C) + assert sounded(PIANO_C_SHARP_UP) + + def a_field_takes_them_as_characters(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + screen.hand.scroll_into_view(field) + screen.hand.click(field) + + assert not sounded(PIANO_C) + assert "z" in instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + screen.scenario(the_keys_sound_it, a_field_takes_them_as_characters, leave_letting_the_project_go).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: an instrument's note keys take Ctrl+Z on the Reconstructions tab", + ) + def test_undo_undoes_with_an_instrument_open(self, screen: Screen) -> None: + """Undo removes the typed volume while the Reconstructions tab shows an instrument.""" + instruments = screen.reconstructions.instruments + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + NOTHING.__eq__, + description="the volume undone", + ) + + +class TestAChannelKeyOnAReconstruction: + """With a reconstruction open, a channel's number key ticks its box above the waveform.""" + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_two_lets_pulse_two_go_and_brings_it_back(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + expect_open(screen, OPEN_RECONSTRUCTION) + assert reconstructions.channel_ticked(ChannelName.PULSE2) + + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + + screen.expect( + lambda: reconstructions.channel_ticked(ChannelName.PULSE2), operator.not_, description="Pulse 2 let go" + ) + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + screen.expect(lambda: reconstructions.channel_ticked(ChannelName.PULSE2), bool, description="Pulse 2 back") diff --git a/tests/screens/reconstructions/instruments/test_pitch_rows.py b/tests/screens/reconstructions/instruments/test_pitch_rows.py new file mode 100644 index 000000000..29c4033e1 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_pitch_rows.py @@ -0,0 +1,190 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import FADING, NOTHING, STILL_FRAMES +from tests.screens.reconstructions.instruments.steps import give_it_a_volume, open_the_instrument, song_title +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import edit_envelope, expect_open +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG + +BENT_ITEM: Final[int] = 2 +FLAT_PITCH: Final[str] = "0 0 0 0 0" +BEND: Final[float] = 40.0 +PITCH_FLOOR: Final[int] = -128 +PITCH_CEILING: Final[int] = 127 + +PULSE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, + FeatureKey.DUTY_CYCLE, +) + +TRIANGLE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, +) + +NOISE_ROWS: Final[Tuple[FeatureKey, ...]] = (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) +PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.pitch_bend" +HI_PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.hi_pitch_bend" + + +class TestTheRowsEachChannelDraws: + """Pulse and triangle tabs draw Pitch and Hi-pitch rows and the noise tab omits them; a bend field + explains a step. + + Each channel tab shows its rows in order and a pitch stepper, and the Audition switch stays hidden. + Hovering the Pitch and Hi-pitch fields shows their bend explanations; hovering Volume shows none. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_rows_per_channel_and_the_bend_explanation(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + expected = { + ChannelName.PULSE1: PULSE_ROWS, + ChannelName.PULSE2: PULSE_ROWS, + ChannelName.TRIANGLE: TRIANGLE_ROWS, + ChannelName.NOISE: NOISE_ROWS, + } + + def each_tab_draws_its_rows(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + for channel, rows in expected.items(): + instruments.bring_forward(channel) + screen.expect(partial(instruments.rows, channel), rows.__eq__, description=f"the rows of {channel}") + assert instruments.offers_pitch_stepper(channel) + + assert not instruments.offers_audition() + + def a_bend_field_explains_a_step(screen: Screen) -> None: + instruments.bring_forward(ChannelName.PULSE1) + for feature, key in ((FeatureKey.PITCH, PITCH_BEND), (FeatureKey.HI_PITCH, HI_PITCH_BEND)): + field = instruments.field(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + + screen.expect(partial(screen.shows_text, screen.words(key)), bool, description=f"{key} shown") + + def the_volume_field_explains_no_bend(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + screen.frames(STILL_FRAMES) + + assert not screen.shows_text(screen.words(PITCH_BEND)) + assert not screen.shows_text(screen.words(HI_PITCH_BEND)) + + screen.scenario(each_tab_draws_its_rows, a_bend_field_explains_a_step, the_volume_field_explains_no_bend).run() + + +class TestAHandWrittenVoice: + """A hand-written voice offers the Audition switch and only its own tab, and Export follows its + envelopes. + + The opened instrument shows the Pulse 1 tab alone, with Export unavailable. A typed volume makes + Export available, and clearing the volume makes it unavailable again. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_stepper_and_export_answers_while_it_sounds(self, screen: Screen) -> None: + """The pitch stepper stays hidden, and Export turns on or off as the volume is typed or cleared.""" + instruments = screen.reconstructions.instruments + + def its_tab_alone(screen: Screen) -> None: + open_the_instrument(screen) + + assert not instruments.offers_pitch_stepper(ChannelName.PULSE1) + assert [channel for channel in ChannelName if instruments.tab_shown(channel)] == [ChannelName.PULSE1] + assert not instruments.can_export(ChannelName.PULSE1) + + def a_volume_brings_export(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) + + screen.expect(lambda: instruments.can_export(ChannelName.PULSE1), bool, description="Export answering") + + def clearing_it_greys_export_out(screen: Screen) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, NOTHING) + + screen.expect( + lambda: instruments.can_export(ChannelName.PULSE1), operator.not_, description="Export greyed out" + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + screen.scenario( + its_tab_alone, + a_volume_brings_export, + clearing_it_greys_export_out, + leave_letting_the_project_go, + ).run() + + +class TestDraggingAPitchBar: + """Dragging a bar of the Pitch row sets that item to the value of the release and keeps the other items + as they were. + + A flat pitch is typed, then one bar is dragged. The field shows the released value, clamped to the + pitch range, for that item alone. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_item_takes_the_value_under_the_release(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.PITCH) + standing: List[int] = [] + + def give_it_a_flat_pitch(screen: Screen) -> None: + give_it_a_volume(screen) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.PITCH, FLAT_PITCH) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH), + FLAT_PITCH.__eq__, + description="the flat pitch typed", + ) + + screen.hand.scroll_into_view(graph.plot) + + standing.extend(int(item) for item in FLAT_PITCH.split()) + + def drag_one_bar(screen: Screen) -> None: + release = graph.drag_item(BENT_ITEM, start=standing[BENT_ITEM], end=BEND) + + _, under = graph.value_under(release) + expected = [*standing] + expected[BENT_ITEM] = min(max(round(under), PITCH_FLOOR), PITCH_CEILING) + screen.expect( + lambda: [int(item) for item in instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH).split()], + expected.__eq__, + description="one item bent", + ) + assert expected[BENT_ITEM] != standing[BENT_ITEM] + + screen.scenario(give_it_a_flat_pitch, drag_one_bar, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_typed_sequences.py b/tests/screens/reconstructions/instruments/test_typed_sequences.py new file mode 100644 index 000000000..73b6e8442 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_typed_sequences.py @@ -0,0 +1,196 @@ +from functools import partial +from pathlib import Path +from typing import Final, List, Optional + +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.project import ProjectContainer +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.reconstructions.instruments.constants import FADING, NOTHING, STILL_FRAMES +from tests.screens.reconstructions.instruments.steps import open_the_instrument, song_title +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import save_project_as +from tests.suite.screens.steps.reconstructions import edit_envelope +from tests.suite.screens.vocabulary.instruments import TOO_LONG +from tests.suite.screens.worlds.recordings import SONG, SONG_INSTRUMENT + +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" +ASIDE: Final[Point] = Point(x=0, y=0) +LOOPING_VOLUME: Final[str] = "15 14 | 12 10" +LOOPING_DUTY: Final[str] = "0 1 | 2" +LOOP_FROM_THE_START: Final[str] = "| 15" +TWO_LOOPS: Final[str] = "15 | | 14" +NOT_A_NUMBER: Final[str] = "x" +TOO_LOUD: Final[str] = "99" +LOUDEST: Final[str] = "15" +TYPING_HINT: Final[str] = "reconstructions.instruments.message.status_sequence" +KEPT_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +INVALID_INPUT: Final[str] = "Invalid PULSE1 data input for VOLUME" + + +def status_over(screen: Screen, item: str) -> str: + """Moves the pointer onto ``item`` from the window's corner, which sets the status line, and reads it.""" + screen.hand.move_to(ASIDE) + screen.hand.scroll_into_view(item) + screen.hand.hover(item) + return screen.status() + + +def saved_instrument(path: Path, name: str) -> Instrument: + """The instrument named ``name`` in the project stored at ``path``.""" + return next( + voice for voice in ProjectContainer.load(path).voices if isinstance(voice, Instrument) and voice.name == name + ) + + +class TestALoopTyped: + """A sequence typed with "|" repeats from the item after it, and a sibling of another length from its + own point. + + The status line explains the "|" before anything is typed. Volume and Duty cycle get one loop each; + after Save as, the stored instrument holds each loop point. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_envelope_keeps_its_own_loop(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def the_status_line_explains_the_bar(screen: Screen) -> None: + open_the_instrument(screen) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + + hint = screen.words(TYPING_HINT).format(instrument_feature=FeatureKey.VOLUME.capitalized) + screen.expect(partial(status_over, screen, field), hint.__eq__, description="the hint about the bar") + + def type_two_loops(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=LOOPING_VOLUME, + title=song_title(screen, unsaved=True), + ) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE, LOOPING_DUTY) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE), + LOOPING_DUTY.__eq__, + description="the duty cycle typed", + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOPING_VOLUME + + def the_project_keeps_each_loop(screen: Screen) -> None: + save_project_as(screen, SAVED_SONG) + + envelopes = saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes + assert (envelopes.volume.items, envelopes.volume.loop_point) == ((15, 14, 12, 10), 2) + assert (envelopes.duty_cycle.items, envelopes.duty_cycle.loop_point) == ((0, 1, 2), 2) + + screen.scenario(the_status_line_explains_the_bar, type_two_loops, the_project_keeps_each_loop).run() + + +class TestSequencesTheFieldAnswers: + """An unreadable sequence is refused and flagged, and a readable one is applied as written. + + A value past the range is brought into it, an empty field clears the dimension, and a sequence + longer than an export keeps is applied with a warning naming what the export keeps. After Save as, + the project holds the last sequence applied. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_refused_clamped_cleared_and_warned(self, screen: Screen) -> None: + """Each kind of typed sequence gets its own answer: an error, a clamp, a clear or a warning.""" + instruments = screen.reconstructions.instruments + long_sequence = " ".join(["8"] * (MAX_SEQUENCE_ITEMS + 1)) + plain: List[Optional[str]] = [] + + def typed(sequence: str) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, sequence) + screen.frames(STILL_FRAMES) + + def theme() -> Optional[str]: + return instruments.field_theme(ChannelName.PULSE1, FeatureKey.VOLUME) + + def unreadable_sequences_are_refused(screen: Screen) -> None: + open_the_instrument(screen) + plain.append(theme()) + + for sequence in (TWO_LOOPS, NOT_A_NUMBER): + typed(sequence) + + screen.claim_error(INVALID_INPUT) + assert theme() == TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == sequence + assert screen.title() == song_title(screen, unsaved=False) + + def a_loop_from_the_start_is_applied(screen: Screen) -> None: + typed(LOOP_FROM_THE_START) + + screen.expect(screen.title, song_title(screen, unsaved=True).__eq__, description="the edit applied") + assert theme() != TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOP_FROM_THE_START + + def a_value_past_the_range_is_brought_into_it(screen: Screen) -> None: + typed(TOO_LOUD) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + LOUDEST.__eq__, + description="the loudest level written", + ) + + def a_sequence_past_every_export_is_applied_with_a_warning(screen: Screen) -> None: + typed(long_sequence) + + screen.expect(theme, TAG_GLOBAL_THEME_INPUT_WARNING.__eq__, description="the warning") + instruments.bring_forward(ChannelName.PULSE1) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + warning = screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=MAX_SEQUENCE_ITEMS + 1, + kept=screen.words(KEPT_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + screen.expect( + partial(status_over, screen, field), warning.__eq__, description="the status naming what each keeps" + ) + + def an_empty_field_clears_it(screen: Screen) -> None: + typed(NOTHING) + + screen.expect(theme, plain[0].__eq__, description="the warning gone") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + def the_project_keeps_what_was_applied_last(screen: Screen) -> None: + typed(FADING) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="the last sequence applied", + ) + + save_project_as(screen, SAVED_SONG) + + assert saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes.volume.items == (12, 8, 4) + + screen.scenario( + unreadable_sequences_are_refused, + a_loop_from_the_start_is_applied, + a_value_past_the_range_is_brought_into_it, + a_sequence_past_every_export_is_applied_with_a_warning, + an_empty_field_clears_it, + the_project_keeps_what_was_applied_last, + ).run() diff --git a/tests/screens/reconstructions/player/__init__.py b/tests/screens/reconstructions/player/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/player/constants.py b/tests/screens/reconstructions/player/constants.py new file mode 100644 index 000000000..836315f30 --- /dev/null +++ b/tests/screens/reconstructions/player/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +RECONSTRUCTION_LINE: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/tests/screens/reconstructions/player/steps.py b/tests/screens/reconstructions/player/steps.py new file mode 100644 index 000000000..af53d4016 --- /dev/null +++ b/tests/screens/reconstructions/player/steps.py @@ -0,0 +1,21 @@ +from tests.suite.screens.screen import Screen +from tests.suite.screens.views.waveform import Waveform + + +def entry(screen: Screen) -> str: + """What the Playback menu's first entry reads: Play while stopped, Pause while playing, Resume while paused.""" + return screen.sequencer.playback.play_entry() + + +def expect_entry(screen: Screen, key: str) -> None: + """Waits until the Playback menu's first entry reads the words of ``key``.""" + screen.expect(lambda: entry(screen), screen.words(key).__eq__, description=f"the Playback menu reading {key}") + + +def advancing(screen: Screen, waveform: Waveform, after: float) -> float: + """Waits until the cursor stands past sample ``after`` and returns where it stands.""" + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: reading > after, + description=f"the cursor moving past sample {after:.0f}", + ) diff --git a/tests/screens/reconstructions/player/test_browser_and_channels.py b/tests/screens/reconstructions/player/test_browser_and_channels.py new file mode 100644 index 000000000..d812d9244 --- /dev/null +++ b/tests/screens/reconstructions/player/test_browser_and_channels.py @@ -0,0 +1,140 @@ +import operator +from typing import Dict, Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.reconstructions.player.constants import RECONSTRUCTION_LINE +from tests.screens.reconstructions.player.steps import advancing, entry, expect_entry +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SECOND_PLAYABLE + +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +SILENCE: Final[float] = 1e-6 +SILENT_SHARE_TOLERANCE: Final[float] = 0.05 + + +def silent_share(drawn: List[float]) -> float: + """The share of drawn heights that sit at silence.""" + return sum(1 for height in drawn if abs(height) < SILENCE) / len(drawn) + + +class TestPlayingFromTheBrowser: + """A reconstruction opened from the browser plays on Space, its cursor moving along the waveform.""" + + def test_space_plays_and_the_cursor_moves_on(self, screen: Screen) -> None: + """The reconstruction opens stopped, and Space starts it with the cursor moving along the waveform.""" + waveform = screen.reconstructions.waveform + + def open_it(screen: Screen) -> None: + load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) + + expect_open(screen, PLAYABLE_RECONSTRUCTION) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def space_plays_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + + screen.scenario(open_it, space_plays_it).run() + + +class TestAnotherReconstructionAfterClosing: + """A reconstruction opened after another was closed draws its cursor as it plays. + + The first reconstruction plays, stops and closes from the menu; the second opens from the browser, plays, + and its cursor moves on. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_its_cursor_moves_on(self, screen: Screen) -> None: + """The second reconstruction plays with its cursor advancing.""" + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + + def play_and_close_the_first(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + reconstructions.close_from_menu() + + screen.expect(reconstructions.file_line, operator.not_, description="nothing open") + + def open_the_second_and_play(screen: Screen) -> None: + load_from_the_browser(screen, SECOND_PLAYABLE) + expect_open(screen, SECOND_PLAYABLE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(play_and_close_the_first, open_the_second_and_play).run() + + +class TestChoosingChannels: + """The waveform draws the channels ticked above it, and ticking one back draws what it drew before. + + The scenario unticks Pulse 1 and Triangle, which leaves the beat alone, and ticks them again. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_beat_alone_falls_silent_between_its_strokes(self, screen: Screen) -> None: + """The beat alone is silent between its strokes; ticking the channels back restores the drawing.""" + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + line = screen.words(RECONSTRUCTION_LINE) + drawn: Dict[str, List[float]] = {} + + def every_channel_sounds_throughout(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + drawn["all"] = waveform.drawn(line) + + assert silent_share(drawn["all"]) == 0.0 + + def the_beat_alone(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + beat = screen.expect( + lambda: waveform.drawn(line), + drawn["all"].__ne__, + description="the waveform redrawn", + ) + assert abs(silent_share(beat) - (BEAT_FRAMES - BEAT_LENGTH) / BEAT_FRAMES) <= SILENT_SHARE_TOLERANCE + + def every_channel_back(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + screen.expect(lambda: waveform.drawn(line), drawn["all"].__eq__, description="the waveform as before") + + screen.scenario(every_channel_sounds_throughout, the_beat_alone, every_channel_back).run() diff --git a/tests/screens/reconstructions/player/test_source_switch.py b/tests/screens/reconstructions/player/test_source_switch.py new file mode 100644 index 000000000..d96c4a198 --- /dev/null +++ b/tests/screens/reconstructions/player/test_source_switch.py @@ -0,0 +1,113 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from tests.screens.reconstructions.player.constants import RECONSTRUCTION_LINE +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, TAKES, playing_world + +ORIGINAL_SWITCH: Final[str] = "reconstructions.reconstruction.label.original_audio_radio" +RECONSTRUCTION_SWITCH: Final[str] = "reconstructions.reconstruction.label.reconstruction_radio" +ORIGINAL_LINE: Final[str] = "global.graph.label.waveform_original" + + +class TestTheSourceSwitch: + """The switch above the waveform puts the source it names on top of the waveform, and back.""" + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_source_chosen_is_drawn_on_top(self, screen: Screen) -> None: + """The reconstruction starts on top, the original takes the top when chosen, and the reconstruction + again. + """ + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + original = waveform.series_tag(screen.words(ORIGINAL_LINE)) + reconstruction = waveform.series_tag(screen.words(RECONSTRUCTION_LINE)) + + def the_reconstruction_on_top(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + assert waveform.series() == (original, reconstruction) + + def the_original_chosen(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(ORIGINAL_SWITCH)) + + screen.expect(waveform.series, (reconstruction, original).__eq__, description="the original on top") + + def the_reconstruction_chosen_again(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(RECONSTRUCTION_SWITCH)) + + screen.expect(waveform.series, (original, reconstruction).__eq__, description="the reconstruction on top") + + screen.scenario(the_reconstruction_on_top, the_original_chosen, the_reconstruction_chosen_again).run() + + +class TestTheSourceSwitchWithItsRecordingGone: + """A reconstruction whose recording is gone offers the reconstruction alone. + + The home lacks the recording. Opening the reconstruction shows a notice naming the missing file; once + dismissed, the switch offers no other source. + """ + + @pytest.fixture + def world(self) -> World: + """The Reconstructions home with the first recording's file removed.""" + world = playing_world() + gone = Path.cwd() / TAKES[0] + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(file for file in world.files if not (isinstance(file, Recording) and file.destination == gone)), + ) + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_switch_stays_on_the_reconstruction(self, screen: Screen) -> None: + """The notice names the missing recording, and the switch offers the reconstruction only.""" + reconstructions = screen.reconstructions + notice = screen.file_not_found_notice + + def the_missing_recording_is_named(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + + assert str(Path.cwd() / TAKES[0]) in notice.words() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + def the_switch_offers_nothing_else(screen: Screen) -> None: + assert not reconstructions.can_choose_audio_source() + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + + screen.scenario(the_missing_recording_is_named, the_switch_offers_nothing_else).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a reconstruction whose recording is missing draws a flat original line", + ) + def test_the_waveform_draws_the_reconstruction_alone(self, screen: Screen) -> None: + """The waveform shows one line, the reconstruction's.""" + waveform = screen.reconstructions.waveform + notice = screen.file_not_found_notice + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + assert waveform.series() == (waveform.series_tag(screen.words(RECONSTRUCTION_LINE)),) diff --git a/tests/screens/reconstructions/player/test_waveform_gestures.py b/tests/screens/reconstructions/player/test_waveform_gestures.py new file mode 100644 index 000000000..3095d0577 --- /dev/null +++ b/tests/screens/reconstructions/player/test_waveform_gestures.py @@ -0,0 +1,271 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.reconstructions.player.steps import advancing, entry, expect_entry +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.waveform import Waveform +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +SAMPLE_RATE: Final[int] = 44100 +PLAYING_WINDOW_SECONDS: Final[float] = 3.0 +STILL_FRAMES: Final[int] = 20 +ZOOM_NOTCHES: Final[int] = 3 +WIDTH_TOLERANCE: Final[float] = 0.01 +SPACE_BURST: Final[int] = 5 +PAST_THE_EDGE: Final[float] = 1.4 +RESUME: Final[str] = "global.menu.label.item_playback_resume" + + +def cursor(waveform: Waveform) -> float: + """The cursor's sample on the waveform, which must be drawn.""" + found = waveform.cursor() + assert found is not None, "no cursor stands on the waveform" + return found + + +def playing_from(screen: Screen, waveform: Waveform, sample: float) -> float: + """Waits for the cursor to stand at ``sample`` or a little past it, where playing from ``sample`` puts it.""" + window = PLAYING_WINDOW_SECONDS * SAMPLE_RATE + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: sample <= reading < sample + window, + description=f"the cursor playing on from sample {sample:.0f}", + ) + + +def held_still(screen: Screen, waveform: Waveform) -> float: + """The cursor's sample, read twice some frames apart and found the same.""" + standing = cursor(waveform) + screen.frames(STILL_FRAMES) + assert waveform.cursor() == standing + return standing + + +def width(limits: Tuple[float, float]) -> float: + """The span a pair of axis limits covers.""" + low, high = limits + return high - low + + +class TestTheWaveformGestures: + """A click plays from a point, moves the cursor while paused and jumps while playing; Space pauses and + resumes. + + A drag pans the view and a double-click fits it, and both leave the cursor and what plays as they were. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_clicks_and_space_steer_playback(self, screen: Screen) -> None: + """A click plays from there, Space pauses and resumes, and Stop takes the cursor away.""" + waveform = screen.reconstructions.waveform + stood: List[float] = [] + + def a_click_while_stopped_plays_from_there(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + + def space_pauses_and_the_cursor_holds(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + held_still(screen, waveform) + + def a_click_while_paused_moves_the_cursor_and_stays_paused(screen: Screen) -> None: + under = waveform.sample_under(0.25) + + waveform.click(0.25) + + screen.expect(waveform.cursor, float(round(under)).__eq__, description="the cursor where clicked") + stood.append(held_still(screen, waveform)) + assert entry(screen) == screen.words(RESUME) + + def space_resumes_from_the_cursor(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, stood[0]) + + def a_click_while_playing_jumps_there(screen: Screen) -> None: + under = waveform.sample_under(0.75) + + waveform.click(0.75) + + playing_from(screen, waveform, under) + assert entry(screen) == screen.words(PAUSE) + + def stop_takes_the_cursor_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + expect_entry(screen, PLAY) + screen.expect(waveform.cursor, operator.not_, description="no cursor") + + screen.scenario( + a_click_while_stopped_plays_from_there, + space_pauses_and_the_cursor_holds, + a_click_while_paused_moves_the_cursor_and_stays_paused, + space_resumes_from_the_cursor, + a_click_while_playing_jumps_there, + stop_takes_the_cursor_away, + ).run() + + def test_a_drag_pans_and_a_double_click_fits_without_touching_playback(self, screen: Screen) -> None: + """A drag pans the zoomed view and a double-click fits it again, while the pause, the playing and the + stop go on as they were. + """ + waveform = screen.reconstructions.waveform + fitted: List[Tuple[float, float]] = [] + stood: List[float] = [] + + def pause_partway(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + fitted.append( + screen.expect(waveform.limits, lambda limits: limits[1] > SAMPLE_RATE, description="the view fitted") + ) + waveform.click(0.5) + expect_entry(screen, PAUSE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + stood.append(held_still(screen, waveform)) + + def zoom_in_and_drag(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + zoomed = screen.expect( + waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed" + ) + + waveform.drag(0.6, 0.4) + + panned = screen.expect(waveform.limits, lambda limits: limits[0] > zoomed[0], description="panned") + assert abs(width(panned) - width(zoomed)) <= WIDTH_TOLERANCE * width(zoomed) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_fits_and_leaves_the_pause(screen: Screen) -> None: + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_leaves_playing_playing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + before = cursor(waveform) + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + assert entry(screen) == screen.words(PAUSE) + advancing(screen, waveform, before) + + def a_double_click_leaves_stopped_stopped(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + screen.scenario( + pause_partway, + zoom_in_and_drag, + a_double_click_fits_and_leaves_the_pause, + a_double_click_leaves_playing_playing, + a_double_click_leaves_stopped_stopped, + ).run() + + +class TestPressesThatAreNoClick: + """A triple click and a drag that leaves the plot play nothing, and the next plain click plays. + + The scenario triple-clicks, then drags past the plot's edge on a zoomed view, and expects the cursor + absent and the menu reading Play both times. A plain click then plays, and Stop ends it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_triple_click_and_a_drag_off_the_plot_play_nothing(self, screen: Screen) -> None: + """The triple click and the drag off the plot leave the cursor absent and the menu reading Play.""" + waveform = screen.reconstructions.waveform + + def a_triple_click(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + waveform.triple_click(0.5) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_drag_off_the_plot(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + waveform.drag(0.5, PAST_THE_EDGE) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_plain_click_plays(screen: Screen) -> None: + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(a_triple_click, a_drag_off_the_plot, a_plain_click_plays).run() + + +class TestABurstOfSpace: + """Five presses of Space from a stop play, pause, resume, pause and resume: the burst ends playing.""" + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_an_odd_burst_ends_playing(self, screen: Screen) -> None: + """After the burst the menu reads Pause and the cursor moves on.""" + waveform = screen.reconstructions.waveform + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + for _ in range(SPACE_BURST): + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + advancing(screen, waveform, cursor(waveform)) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) diff --git a/tests/screens/reconstructions/stems/__init__.py b/tests/screens/reconstructions/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/stems/test_stems_card.py b/tests/screens/reconstructions/stems/test_stems_card.py new file mode 100644 index 000000000..80f64bf7e --- /dev/null +++ b/tests/screens/reconstructions/stems/test_stems_card.py @@ -0,0 +1,286 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser, marked, titled +from tests.suite.screens.views.stems import StemsCard +from tests.suite.screens.vocabulary.playback import PAUSE +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION, STEM_TAKES, STEMS_RECONSTRUCTION + +KEYS: Final[Tuple[str, ...]] = ("0", "1", "2") +FIRST: Final[str] = "0" +MIDDLE: Final[str] = "1" +LAST: Final[str] = "2" +STILL_FRAMES: Final[int] = 20 +SILENT: Final[float] = 0.4 +PLAYING_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +REMOVE_TITLE: Final[str] = "reconstructions.reconstruction.title.remove_stem_dialog" +REMOVE_MESSAGE: Final[str] = "reconstructions.reconstruction.message.remove_stem_message" +NOTHING_EXPORTED: Final[str] = "global.context.template.size_bytes" + + +def take(index: int) -> Path: + """The path of the stem recording at ``index``, under the working directory.""" + return Path.cwd() / STEM_TAKES[index] + + +def swatches(stems: StemsCard, keys: Tuple[str, ...]) -> Dict[str, Tuple[float, ...]]: + """The color of each row of ``keys`` on the stems card.""" + return {key: stems.swatch(key) for key in keys} + + +def stems_title(screen: Screen, *, unsaved: bool) -> str: + """The window title of the stems reconstruction, marked unsaved or saved.""" + return titled(screen, marked(STEMS_RECONSTRUCTION.name, unsaved=unsaved)) + + +class TestRevealingARecording: + """A double-click on a row shows its recording in the file manager; a click shows nothing; no row stays lit. + + The scenario clicks a row, double-clicks the first row and then the last, and expects the recordings shown + in that order and no row highlighted. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_a_double_click_shows_the_recording_and_leaves_the_row_plain(self, screen: Screen) -> None: + """Each double-click shows its own recording, and the rows keep their plain look.""" + stems = screen.reconstructions.stems + + def a_click_shows_nothing(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + + stems.click(FIRST) + + screen.frames(STILL_FRAMES) + assert screen.revealed() == () + assert not any(stems.is_highlighted(key) for key in KEYS) + + def a_double_click_shows_that_recording(screen: Screen) -> None: + stems.reveal(FIRST) + + screen.expect(screen.revealed, (take(0),).__eq__, description="the first recording shown") + assert not any(stems.is_highlighted(key) for key in KEYS) + + def another_row_shows_its_own(screen: Screen) -> None: + stems.reveal(LAST) + + screen.expect(screen.revealed, (take(0), take(2)).__eq__, description="the last recording shown") + assert not any(stems.is_highlighted(key) for key in KEYS) + + screen.scenario(a_click_shows_nothing, a_double_click_shows_that_recording, another_row_shows_its_own).run() + + +class TestRemovingARecording: + """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go. + + The scenario cancels the question, then removes the middle recording and confirms. The rest play, the + first goes too, and the last recording stays without a remove gesture. Saved and opened again, the card + lists only the last recording in its old color. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_cancel_keeps_it_and_remove_leaves_the_rest_as_they_were(self, screen: Screen) -> None: + """Cancel keeps every row; Remove drops one, and the others keep their colors and play.""" + reconstructions = screen.reconstructions + stems = reconstructions.stems + prompt = stems.remove_prompt + colors: List[Dict[str, Tuple[float, ...]]] = [] + + def the_file_named(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + + assert screen.title() == stems_title(screen, unsaved=False) + assert all(stems.has_row(key) for key in KEYS) + colors.append(swatches(stems, KEYS)) + + def cancel_keeps_it(screen: Screen) -> None: + stems.remove(MIDDLE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + assert prompt.title() == screen.words(REMOVE_TITLE) + assert screen.words(REMOVE_MESSAGE) in prompt.words() + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(STILL_FRAMES) + assert all(stems.has_row(key) for key in KEYS) + assert screen.title() == stems_title(screen, unsaved=False) + + def remove_the_middle(screen: Screen) -> None: + stems.remove(MIDDLE) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(lambda: stems.has_row(MIDDLE), operator.not_, description="the middle recording gone") + assert screen.title() == stems_title(screen, unsaved=True) + assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + + def the_rest_plays(screen: Screen) -> None: + waveform = reconstructions.waveform + + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + screen.expect(lambda: waveform.cursor() or 0.0, lambda reading: reading > first, description="moving on") + screen.press_shortcut(ShortcutId.STOP) + + def the_last_cannot_go(screen: Screen) -> None: + stems.remove(FIRST) + screen.expect(prompt.is_shown, bool, description="the question about the first") + prompt.confirm() + screen.expect(lambda: stems.has_row(FIRST), operator.not_, description="the first recording gone") + + assert not stems.can_remove(LAST) + assert stems.swatch(LAST) == colors[0][LAST] + + def saved_and_opened_again_it_keeps_its_color(screen: Screen) -> None: + reconstructions.save_from_menu() + screen.expect(screen.title, stems_title(screen, unsaved=False).__eq__, description="saved") + reconstructions.close_from_menu() + screen.expect(reconstructions.file_line, operator.not_, description="closed") + + load_from_the_browser(screen, STEMS_RECONSTRUCTION) + + expect_open(screen, STEMS_RECONSTRUCTION) + screen.expect(lambda: stems.has_row(LAST), bool, description="the last recording listed") + assert not stems.has_row(FIRST) + assert not stems.has_row(MIDDLE) + assert stems.swatch(LAST) == colors[0][LAST] + + screen.scenario( + the_file_named, + cancel_keeps_it, + remove_the_middle, + the_rest_plays, + the_last_cannot_go, + saved_and_opened_again_it_keeps_its_color, + ).run() + + +class TestSilencingARecording: + """A recording whose every frame falls silent leaves the card, and the others keep their colors. + + With the middle recording heard alone, each channel's volume is dragged to silence across every bar, which + leaves the channel standing by for that recording. + + The scenario ends on the middle row gone and the other rows in their old colors, then leaves the + application. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_its_row_leaves_and_the_others_keep_their_colors(self, screen: Screen) -> None: + """The silenced recording's row leaves the card and the other rows keep their colors.""" + reconstructions = screen.reconstructions + stems = reconstructions.stems + instruments = reconstructions.instruments + colors: List[Dict[str, Tuple[float, ...]]] = [] + + def hear_the_middle_alone(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + colors.append(swatches(stems, KEYS)) + + for key in (FIRST, LAST): + stems.tick(key) + screen.expect(partial(stems.hears, key), operator.not_, description=f"{key} left unheard") + + def silence_every_channel_of_it(screen: Screen) -> None: + for channel in PLAYING_CHANNELS: + instruments.bring_forward(channel) + graph = instruments.graph(channel, FeatureKey.VOLUME) + screen.hand.scroll_into_view(graph.plot) + + bars = len(instruments.envelope(channel, FeatureKey.VOLUME).split()) + + graph.drag_across(0, bars - 1, value=SILENT) + + screen.expect( + partial(instruments.envelope, channel, FeatureKey.VOLUME), + operator.not_, + description=f"{channel} standing by", + ) + + def its_row_leaves(screen: Screen) -> None: + screen.expect(lambda: stems.has_row(MIDDLE), operator.not_, description="the middle recording gone") + + assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + + def leave_letting_it_go(screen: Screen) -> None: + prompt = reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(hear_the_middle_alone, silence_every_channel_of_it, its_row_leaves, leave_letting_it_go).run() + + +class TestLettingAChannelGo: + """A channel whose volume is dragged to silence on every frame mutes its tab and exports nothing. + + The silence is dragged at a height that rounds to it, above the strip under the bars that a press ignores. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_the_tab_mutes_and_its_size_reads_nothing(self, screen: Screen) -> None: + """The channel's tab takes the muted look, its size reads zero bytes and its export is unavailable.""" + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + + def drag_pulse_one_to_silence(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + assert instruments.tab_theme(ChannelName.PULSE1) != TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED + + bars = len(instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME).split()) + + graph.drag_across(0, bars - 1, value=SILENT) + + screen.expect( + lambda: instruments.tab_theme(ChannelName.PULSE1), + TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED.__eq__, + description="the tab muted", + ) + assert instruments.size(ChannelName.PULSE1) == screen.words(NOTHING_EXPORTED).format(bytes=0) + assert not instruments.can_export(ChannelName.PULSE1) + + def leave_letting_it_go(screen: Screen) -> None: + prompt = reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(drag_pulse_one_to_silence, leave_letting_it_go).run() diff --git a/tests/screens/sequencer/__init__.py b/tests/screens/sequencer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/colors/__init__.py b/tests/screens/sequencer/colors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/colors/test_kind_colors.py b/tests/screens/sequencer/colors/test_kind_colors.py new file mode 100644 index 000000000..30a31079b --- /dev/null +++ b/tests/screens/sequencer/colors/test_kind_colors.py @@ -0,0 +1,203 @@ +import operator +from functools import partial +from typing import Final, List, Optional, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_OFF +from tests.suite.screens.dearpygui.items.colors import rounded_color +from tests.suite.screens.palettes import Color, shipped_palettes, token_color +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go +from tests.suite.screens.views.history import HistoryLine +from tests.suite.screens.worlds.songs import BASS_ROW, BASS_VOICE, LINE, PAD, PAD_ROW + +SAMPLE_TOKEN: Final[str] = "voice_sample" +INSTRUMENT_TOKEN: Final[str] = "voice_instrument" +NEUTRAL_TOKEN: Final[str] = "text_disabled" +CUT_KEY: Final[str] = "-" +FIRST_ROW: Final[int] = 0 +EMPTY_ROW: Final[int] = 1 +CUT_ROW: Final[int] = 2 +PLACED_ROW: Final[int] = 3 +TYPING_FRAMES: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 10 +RGB: Final[int] = 3 +ALPHA: Final[int] = 3 +LINE_NUMBER: Final[str] = "00" +FIRST_NUMBER: Final[str] = "01" +PAD_NUMBER: Final[str] = "02" +POSITION_MARK: Final[str] = ":" + + +def type_into(screen: Screen, row: int, channel: ChannelName, text: str) -> None: + """Clicks the voice slot of a channel at a row, types the text and lets the typing settle.""" + screen.sequencer.tracker.click(row, channel, SubColumn.VOICE) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + +def kind_mark(screen: Screen, voice: str) -> Color: + """The rounded color of the kind mark beside the voice called ``voice`` in the list.""" + return rounded_color(screen.sequencer.voices.kind_color(voice)) + + +def voice_color(screen: Screen, row: int, channel: ChannelName) -> Optional[Color]: + """The text color of a channel's voice cell at a row, or None when the cell shows none.""" + return screen.sequencer.tracker.text_color(row, channel, SubColumn.VOICE) + + +def position_color(line: HistoryLine, position: str) -> Color: + """The color of the piece of ``line`` naming a voice by its ``position``.""" + return rounded_color( + next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + ) + + +def is_dimmed(color: Optional[Color], full: Color) -> bool: + """Whether ``color`` is ``full`` dimmed: the same hue, drawn fainter.""" + return color is not None and color[:RGB] == full[:RGB] and color[ALPHA] < full[ALPHA] + + +class TestKindColorsInEveryPalette: + """A recording reads in the sample color and a hand-written voice in the instrument color, as kind marks and as tracker cells, in every palette and live on a swap. + + An empty cell and a cut read neutral, and a muted channel's cells keep their hue, fainter. The scenario cuts a cell and mutes the triangle, then checks the default palette and every shipped palette in Display settings. + """ + + def test_marks_and_cells_take_every_palette(self, screen: Screen) -> None: + """Marks and cells take the colors of each palette as it is chosen.""" + tracker = screen.sequencer.tracker + settings = screen.display_settings + catalog = shipped_palettes() + + def cut_a_cell_and_mute_the_triangle(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + sample = token_color(catalog.get(DEFAULT_PALETTE_NAME), SAMPLE_TOKEN) + assert voice_color(screen, BASS_ROW, ChannelName.TRIANGLE) == sample + + type_into(screen, CUT_ROW, ChannelName.PULSE1, CUT_KEY) + tracker.click_header(ChannelName.TRIANGLE) + + screen.expect( + partial(tracker.label, CUT_ROW, ChannelName.PULSE1, SubColumn.VOICE), + NOTE_OFF.__eq__, + description="the cut", + ) + screen.expect( + partial(voice_color, screen, BASS_ROW, ChannelName.TRIANGLE), + partial(is_dimmed, full=sample), + description="the muted triangle's cell dimmed", + ) + + def painted_in(screen: Screen, palette: Palette) -> None: + sample = token_color(palette, SAMPLE_TOKEN) + instrument = token_color(palette, INSTRUMENT_TOKEN) + neutral = token_color(palette, NEUTRAL_TOKEN) + + assert kind_mark(screen, LINE) == sample + assert kind_mark(screen, BASS_VOICE) == sample + assert kind_mark(screen, PAD) == instrument + assert voice_color(screen, FIRST_ROW, ChannelName.PULSE1) == sample + assert voice_color(screen, PAD_ROW, ChannelName.PULSE2) == instrument + assert voice_color(screen, EMPTY_ROW, ChannelName.PULSE1) == neutral + assert voice_color(screen, CUT_ROW, ChannelName.PULSE1) == neutral + assert is_dimmed(voice_color(screen, BASS_ROW, ChannelName.TRIANGLE), sample) + + def every_palette_paints_the_kinds(screen: Screen) -> None: + painted_in(screen, catalog.get(DEFAULT_PALETTE_NAME)) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + for name in catalog.names: + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + + painted_in(screen, catalog.get(name)) + + settings.choose_palette(DEFAULT_PALETTE_NAME) + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario( + cut_a_cell_and_mute_the_triangle, + every_palette_paints_the_kinds, + leave_letting_the_project_go, + ).run() + + +class TestColorsFollowTheVoice: + """A voice's color travels with it: a placement's history line names it in its color, a move re-numbers and re-colors its cells and leaves the others alone, and the lines naming it keep its color once it is removed. + + An instrument is placed, moved up one place and removed after a confirmation. + """ + + def test_placed_moved_and_removed(self, screen: Screen) -> None: + """Each step shows the instrument's number and color in the cells and in the history.""" + tracker = screen.sequencer.tracker + voices = screen.sequencer.voices + history = screen.sequencer.history + palette = shipped_palettes().get(DEFAULT_PALETTE_NAME) + sample = token_color(palette, SAMPLE_TOKEN) + instrument = token_color(palette, INSTRUMENT_TOKEN) + placed: List[Tuple[HistoryLine, int]] = [] + + def place_the_instrument(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + before = len(history.lines()) + + type_into(screen, PLACED_ROW, ChannelName.PULSE1, PAD_NUMBER) + + lines = screen.expect(history.lines, lambda found: len(found) == before + 1, description="its entry") + assert position_color(lines[0], PAD_NUMBER) == instrument + assert voice_color(screen, PLACED_ROW, ChannelName.PULSE1) == instrument + placed.append((lines[0], before + 1)) + + def moving_it_up_renumbers_its_cells_alone(screen: Screen) -> None: + row = screen.expect_item(partial(voices.row, PAD), description="the instrument's row") + voices.pick(row) + + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP) + + screen.expect(voices.names, [LINE, PAD, BASS_VOICE].__eq__, description="the instrument moved up") + screen.expect( + partial(tracker.label, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE), + FIRST_NUMBER.__eq__, + description="its cell renumbered", + ) + for row_index, channel, number, color in ( + (PAD_ROW, ChannelName.PULSE2, FIRST_NUMBER, instrument), + (PLACED_ROW, ChannelName.PULSE1, FIRST_NUMBER, instrument), + (BASS_ROW, ChannelName.TRIANGLE, PAD_NUMBER, sample), + (FIRST_ROW, ChannelName.PULSE1, LINE_NUMBER, sample), + ): + assert tracker.label(row_index, channel, SubColumn.VOICE) == number + assert voice_color(screen, row_index, channel) == color + + def removed_its_older_lines_keep_its_color(screen: Screen) -> None: + prompt = voices.remove_prompt + row = screen.expect_item(partial(voices.row, PAD), description="the instrument's row") + voices.pick(row) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + + prompt.confirm() + + screen.expect(voices.names, [LINE, BASS_VOICE].__eq__, description="the instrument removed") + line, count = placed[0] + lines = history.lines() + older = lines[len(lines) - count] + assert older.segments == line.segments + assert position_color(older, PAD_NUMBER) == instrument + forgive_the_hover_race(screen) + + screen.scenario( + place_the_instrument, + moving_it_up_renumbers_its_cells_alone, + removed_its_older_lines_keep_its_color, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/conftest.py b/tests/screens/sequencer/conftest.py new file mode 100644 index 000000000..ee0964be9 --- /dev/null +++ b/tests/screens/sequencer/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + + +@pytest.fixture +def world() -> World: + """The home every Sequencer scenario starts in: an arranged project, and reconstructions to add to it.""" + return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on the arranged project with no reconstruction loaded.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/sequencer/history/__init__.py b/tests/screens/sequencer/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/history/cases.py b/tests/screens/sequencer/history/cases.py new file mode 100644 index 000000000..c9ef45e4c --- /dev/null +++ b/tests/screens/sequencer/history/cases.py @@ -0,0 +1,46 @@ +import operator +from functools import partial +from typing import Callable, Final, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.steps import pick, rename +from tests.suite.screens.screen import Screen + +DUPLICATE: Final[str] = "sequencer.voices.label.context_duplicate" +Gesture = Callable[[Screen, str], None] + + +def duplicate(screen: Screen, name: str) -> None: + """Duplicates the voice ``name`` from the context menu of its row.""" + voices = screen.sequencer.voices + menu = screen.context_menu + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(screen.expect_item(partial(voices.row, name), description=f"the row of {name}")) + screen.expect(menu.is_shown, bool, description="the row's menu") + menu.choose(screen.words(DUPLICATE)) + + +def move(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place, down where a place lies below it and up otherwise.""" + last = screen.sequencer.voices.names()[-1] == name + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP if last else ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def remove(screen: Screen, name: str) -> None: + """Removes the voice ``name`` by shortcut and confirms the question about removing.""" + prompt = screen.sequencer.voices.remove_prompt + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + prompt.confirm() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + + +GESTURES: Final[Tuple[Tuple[str, Gesture], ...]] = ( + ("rename", rename), + ("duplicate", duplicate), + ("move", move), + ("remove", remove), +) diff --git a/tests/screens/sequencer/history/constants.py b/tests/screens/sequencer/history/constants.py new file mode 100644 index 000000000..36ae797f6 --- /dev/null +++ b/tests/screens/sequencer/history/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 +RENAMED: Final[str] = "Renamed" +PAD_POSITION: Final[str] = "02" +POSITION_MARK: Final[str] = ":" diff --git a/tests/screens/sequencer/history/steps.py b/tests/screens/sequencer/history/steps.py new file mode 100644 index 000000000..c0ac94f4d --- /dev/null +++ b/tests/screens/sequencer/history/steps.py @@ -0,0 +1,32 @@ +from functools import partial + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.constants import RENAMED, SETTLING_FRAMES +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.screen import Screen + + +def pick(screen: Screen, name: str) -> None: + """Opens the Sequencer tab, picks the row of the voice ``name`` and lets the screen settle.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + row = screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + voices.pick(row) + screen.frames(SETTLING_FRAMES) + + +def rename(screen: Screen, name: str) -> None: + """Renames the voice ``name`` to the fixed new name: picks it, edits it and confirms.""" + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) diff --git a/tests/screens/sequencer/history/test_envelope_drag.py b/tests/screens/sequencer/history/test_envelope_drag.py new file mode 100644 index 000000000..85b01dd6f --- /dev/null +++ b/tests/screens/sequencer/history/test_envelope_drag.py @@ -0,0 +1,70 @@ +from typing import Final, List, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.sequencer.history.constants import PAD_POSITION, POSITION_MARK +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice +from tests.suite.screens.views.history import HistoryLine +from tests.suite.screens.worlds.songs import PAD + +VOLUME_LETTER: Final[str] = "v" +ARPEGGIO_LETTER: Final[str] = "a" +QUIET: Final[float] = 4.0 +ARPEGGIO_STEP: Final[float] = 5.0 +DRAGGED_ITEM: Final[int] = 1 + + +def position_color(line: HistoryLine, position: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice by its ``position``.""" + return next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + + +class TestAnEnvelopeDragIsOneEntry: + """A drag across an instrument's envelope makes one history entry naming the voice and the dimension. + + The pad instrument is opened and its volume envelope is dragged: one entry appears, naming the + instrument by its number in the color of its kind. A drag across the arpeggio envelope adds a second + entry. One undo takes back the arpeggio drag only, and the project is left. + """ + + def test_one_entry_per_dimension(self, screen: Screen) -> None: + """Each drag makes exactly one entry, and one undo reverts one drag.""" + instruments = screen.reconstructions.instruments + history = screen.sequencer.history + lines: List[int] = [] + standing: List[str] = [] + + def drag(feature: FeatureKey, end: float) -> None: + graph = instruments.graph(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(graph.plot) + graph.drag_item(DRAGGED_ITEM, start=0.0, end=end) + + def drag_the_volume(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + lines.append(len(history.lines())) + + drag(FeatureKey.VOLUME, QUIET) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 1, description="one entry") + assert position_color(after[0], PAD_POSITION) == screen.sequencer.voices.kind_color(PAD) + assert VOLUME_LETTER in after[0].words.split() + + def drag_the_arpeggio(screen: Screen) -> None: + standing.append(instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO)) + + drag(FeatureKey.ARPEGGIO, ARPEGGIO_STEP) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 2, description="a second entry") + assert ARPEGGIO_LETTER in after[0].words.split() + + def one_undo_takes_one_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(lambda: history.lines()[1].current, bool, description="one step back") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) == standing[0] + + screen.scenario(drag_the_volume, drag_the_arpeggio, one_undo_takes_one_back, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/history/test_voice_gestures.py b/tests/screens/sequencer/history/test_voice_gestures.py new file mode 100644 index 000000000..f0001abc0 --- /dev/null +++ b/tests/screens/sequencer/history/test_voice_gestures.py @@ -0,0 +1,158 @@ +from typing import Callable, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.cases import GESTURES, Gesture +from tests.screens.sequencer.history.constants import PAD_POSITION, POSITION_MARK +from tests.screens.sequencer.history.steps import pick, rename +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import titled +from tests.suite.screens.steps.sequencer import forgive_the_hover_race +from tests.suite.screens.views.history import HistoryLine +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +LINE_POSITION: Final[str] = "00" + + +def move_down(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place down.""" + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def done(lines: Tuple[HistoryLine, ...]) -> Tuple[HistoryLine, ...]: + """The lines of the entries done, from the one in force down, leaving out those an undo set aside.""" + current = next(index for index, line in enumerate(lines) if line.current) + return lines[current:] + + +def voice_segment_color(line: HistoryLine, name: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice ``name``.""" + return next(segment.color for segment in line.segments if name in segment.words) + + +def naming_color(line: HistoryLine, voice: str, position: str) -> Tuple[float, ...]: + """The color of the first piece of ``line`` naming the voice, by its name or by its ``position``.""" + return next( + segment.color + for segment in line.segments + if voice in segment.words or segment.words.rstrip(POSITION_MARK) == position + ) + + +def one_more_than(count: int) -> Callable[[Tuple[HistoryLine, ...]], bool]: + """Whether a reading of the lines done holds one more than ``count``.""" + return lambda lines: len(lines) == count + 1 + + +def names_by_position_and_name(line: HistoryLine, voice: str, position: str) -> bool: + """Whether ``line`` names the voice both by its ``position`` and by its name.""" + words = [segment.words.rstrip(POSITION_MARK) for segment in line.segments] + return position in words and any(voice in word for word in words) + + +def one_entry_per_gesture(gesture_name: str, gesture: Gesture, voice: str, position: str) -> Callable[[Screen], None]: + """The step making ``gesture`` on ``voice``: one entry in its kind's color, undone and redone.""" + + def step(screen: Screen) -> None: + voices = screen.sequencer.voices + history = screen.sequencer.history + before_names = voices.names() + before = done(history.lines()) + kind = voices.kind_color(voice) + + gesture(screen, voice) + + after = screen.expect( + lambda: done(history.lines()), lambda lines: len(lines) == len(before) + 1, description="one entry" + ) + after_names = voices.names() + assert after_names != before_names + assert [line.segments for line in after[1:]] == [line.segments for line in before] + assert naming_color(after[0], voice, position) == kind + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, before_names.__eq__, description=f"{gesture_name} undone") + assert [line.segments for line in done(history.lines())] == [line.segments for line in before] + screen.press_shortcut(ShortcutId.REDO) + screen.expect(voices.names, after_names.__eq__, description=f"{gesture_name} redone") + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, before_names.__eq__, description=f"{gesture_name} undone again") + + step.__name__ = f"{gesture_name}_{voice.lower()}_is_one_entry" + return step + + +def leave_as_opened(screen: Screen) -> None: + """Exits a project whose gestures were all undone; the window closes at once.""" + forgive_the_hover_race(screen) + assert screen.title() == titled(screen, ARRANGED_PROJECT.stem) + + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestEachVoiceGestureIsOneEntry: + """Renaming, duplicating, moving and removing a sample or an instrument each make one entry. + + The entry names the voice in the color of its kind, and one undo and one redo take it back and forth. + """ + + def test_a_sample_and_an_instrument(self, screen: Screen) -> None: + """Each of the four gestures on the sample and on the pad instrument adds one entry, and undo, redo and + undo again move the voice list back and forth. + """ + steps = [ + one_entry_per_gesture(name, gesture, voice, position) + for voice, position in ((LINE, LINE_POSITION), (PAD, PAD_POSITION)) + for name, gesture in GESTURES + ] + + screen.scenario(*steps, leave_as_opened).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: the history lines of a renamed or a moved voice name it one way alone", + ) + def test_every_line_names_the_voice_by_position_and_name(self, screen: Screen) -> None: + """The entries of a renamed and of a moved sample each name it by its position and by its name.""" + history = screen.sequencer.history + lines: List[HistoryLine] = [] + for gesture in (rename, move_down): + before = len(done(history.lines())) + gesture(screen, LINE) + lines.append( + screen.expect(lambda: done(history.lines()), one_more_than(before), description="its entry")[0] + ) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(screen.sequencer.voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="undone") + + forgive_the_hover_race(screen) + assert all(names_by_position_and_name(line, LINE, LINE_POSITION) for line in lines) + + def test_a_new_instrument_is_one_entry(self, screen: Screen) -> None: + """A new instrument adds one entry in the instrument color, and one undo removes the instrument.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + + def add_one(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + before = history.lines() + + voices.new_instrument() + + after = screen.expect(history.lines, lambda lines: len(lines) == len(before) + 1, description="one entry") + assert len(voices.names()) == 4 + name = voices.names()[-1] + assert voice_segment_color(after[0], name) == voices.kind_color(name) + assert voices.kind_color(name) == voices.kind_color(PAD) + + def undo_takes_it_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the instrument gone") + + screen.scenario(add_one, undo_takes_it_away, leave_as_opened).run() diff --git a/tests/screens/sequencer/song/__init__.py b/tests/screens/sequencer/song/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/song/constants.py b/tests/screens/sequencer/song/constants.py new file mode 100644 index 000000000..0ef134494 --- /dev/null +++ b/tests/screens/sequencer/song/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 20 +EMPTY_VOICE: Final[str] = ".." +LINE_NUMBER: Final[str] = "00" diff --git a/tests/screens/sequencer/song/test_master_gain.py b/tests/screens/sequencer/song/test_master_gain.py new file mode 100644 index 000000000..7e001860f --- /dev/null +++ b/tests/screens/sequencer/song/test_master_gain.py @@ -0,0 +1,69 @@ +import math +import operator +from typing import Final, List, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + +UNITY: Final[float] = 1.0 +LOUDEST_GAIN: Final[float] = 2.0 +BEYOND_THE_LEFT: Final[float] = -0.5 +BEYOND_THE_RIGHT: Final[float] = 1.5 +NEAR_THE_RIGHT: Final[float] = 0.97 +FROM_THE_MIDDLE: Final[float] = 0.5 +DECIBELS_PER_DOUBLING: Final[float] = 20.0 +SILENT_GAIN: Final[str] = "settings.audio.message.master_gain_silent" +GAIN_TEMPLATE: Final[str] = "settings.audio.template.master_gain_db" + + +class TestTheMasterGain: + """The decibel line of Audio settings follows the master gain slider. + + The dialog opens at unity. Dragging the slider past the left end reads silence in the unity color; + dragging past the right end reads the top level in the warning color; dragging back to the middle + reads that level in decibels, and Cancel closes the dialog. + """ + + def test_the_line_follows_the_slider(self, screen: Screen) -> None: + """The decibel line shows silence, the top level and a middle level as the slider moves.""" + settings = screen.audio_settings + unity_color: List[Tuple[float, ...]] = [] + + def open_audio_settings(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) + screen.expect(settings.is_shown, bool, description="Audio settings") + + assert settings.gain() == UNITY + unity_color.append(settings.decibels_color()) + + def all_the_way_down_reads_silence(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_LEFT) + + screen.expect(settings.gain, (0.0).__eq__, description="the gain at zero") + assert settings.decibels() == screen.words(SILENT_GAIN) + assert settings.decibels_color() == unity_color[0] + + def all_the_way_up_reads_the_level_in_warning(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_RIGHT) + + screen.expect(settings.gain, LOUDEST_GAIN.__eq__, description="the gain at its top") + level = DECIBELS_PER_DOUBLING * math.log10(LOUDEST_GAIN) + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format(decibels=level) + assert settings.decibels_color() != unity_color[0] + + def back_to_unity(screen: Screen) -> None: + settings.drag_gain(NEAR_THE_RIGHT, FROM_THE_MIDDLE) + + gain = screen.expect(settings.gain, lambda found: 0.0 < found < LOUDEST_GAIN, description="a middle gain") + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format( + decibels=DECIBELS_PER_DOUBLING * math.log10(gain) + ) + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + + screen.scenario( + open_audio_settings, + all_the_way_down_reads_silence, + all_the_way_up_reads_the_level_in_warning, + back_to_unity, + ).run() diff --git a/tests/screens/sequencer/song/test_order_and_highlights.py b/tests/screens/sequencer/song/test_order_and_highlights.py new file mode 100644 index 000000000..da9ad057b --- /dev/null +++ b/tests/screens/sequencer/song/test_order_and_highlights.py @@ -0,0 +1,89 @@ +import operator +from functools import partial +from typing import Final + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.song.constants import EMPTY_VOICE, LINE_NUMBER +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer + +FIRST_PATTERN: Final[str] = "00" +SECOND_PATTERN: Final[str] = "01" +FIRST_HIGHLIGHT: Final[int] = 3 +SECOND_HIGHLIGHT: Final[int] = 6 +TINTED_ROWS_READ: Final[int] = 13 + + +class TestTheOrderTable: + """Typing an order entry names the pattern a channel plays at that frame, and the tracker follows it. + + Pattern 01 typed on the first frame of the first pulse channel empties its first line; typing 00 again + brings the line back. + """ + + def test_patterns_follow_the_order(self, screen: Screen) -> None: + """The tracker shows the pattern the order entry names.""" + order = screen.sequencer.order + tracker = screen.sequencer.tracker + + def a_new_pattern_number_shows_a_new_pattern(screen: Screen) -> None: + on_the_sequencer(screen) + assert order.label(ChannelName.PULSE1, 0) == FIRST_PATTERN + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(SECOND_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), SECOND_PATTERN.__eq__, description="pattern 01") + assert tracker.label(0, ChannelName.PULSE1, SubColumn.VOICE) == EMPTY_VOICE + + def the_first_number_brings_the_line_back(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(FIRST_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), FIRST_PATTERN.__eq__, description="pattern 00") + screen.expect( + partial(tracker.label, 0, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the line back", + ) + + screen.scenario( + a_new_pattern_number_shows_a_new_pattern, + the_first_number_brings_the_line_back, + leave_letting_the_project_go, + ).run() + + +class TestTheHighlights: + """The tracker tints the rows the project's highlights name. + + Highlights of three and six are typed in Project properties; every third row ends up tinted. + """ + + def test_rows_on_the_highlight_are_tinted(self, screen: Screen) -> None: + """Exactly the rows on the first highlight are tinted.""" + properties = screen.project.properties + tracker = screen.sequencer.tracker + + def set_highlights_of_three_and_six(screen: Screen) -> None: + on_the_sequencer(screen) + properties.open() + screen.expect(properties.is_shown, bool, description="Project properties") + + properties.retype_highlights(FIRST_HIGHLIGHT, SECOND_HIGHLIGHT) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="Project properties closed") + + def every_third_row_is_tinted(screen: Screen) -> None: + expected = [row % FIRST_HIGHLIGHT == 0 for row in range(TINTED_ROWS_READ)] + + screen.expect( + lambda: [tracker.is_row_tinted(row) for row in range(TINTED_ROWS_READ)], + expected.__eq__, + description="every third row tinted", + ) + + screen.scenario(set_highlights_of_three_and_six, every_third_row_is_tinted, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/song/test_retuning_and_saving.py b/tests/screens/sequencer/song/test_retuning_and_saving.py new file mode 100644 index 000000000..d0597f037 --- /dev/null +++ b/tests/screens/sequencer/song/test_retuning_and_saving.py @@ -0,0 +1,107 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.sequencer.song.constants import LINE_NUMBER, SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import marked, raise_the_first_level, titled, voice_title +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer, open_voice +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, LINE + +LINE_ORDINAL: Final[int] = 0 +FIFTY_HERTZ: Final[int] = 50 +SIXTY_HERTZ: Final[int] = 60 +REFIT_TOLERANCE: Final[float] = 0.02 + + +class TestRetuningWithASampleOpen: + """A new NES frequency re-fits the sample open on the Reconstructions tab and keeps its edit. + + The edit is made on the noise, whose last frame is silent, so the sample keeps its frames and the + waveform stretches by the ratio of the two rates. The scenario opens the sample, raises a level, + retypes the frequency from 60 to 50 Hz, confirms the retune and expects the stretched waveform and + the same envelope. + """ + + def test_the_waveform_refits_and_the_edit_stays(self, screen: Screen) -> None: + """The waveform stretches by the ratio of the rates and the edited envelope stays.""" + module = screen.sequencer.module + reconstructions = screen.reconstructions + typed: List[str] = [] + limits: List[Tuple[float, float]] = [] + + def edit_the_line(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=False).__eq__, + description="the line open", + ) + + typed.append( + raise_the_first_level( + screen, + ChannelName.NOISE, + title=voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=True), + ) + ) + limits.append(reconstructions.waveform.limits()) + + def retune_to_fifty(screen: Screen) -> None: + on_the_sequencer(screen) + assert module.nes_frequency() == SIXTY_HERTZ + + module.retype_nes_frequency(FIFTY_HERTZ) + + screen.expect(module.retune_prompt.is_shown, bool, description="the question about retuning") + module.retune_prompt.confirm() + screen.expect(module.retune_prompt.is_shown, operator.not_, description="the question answered") + + def the_waveform_refits_and_the_edit_stays(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + low, high = limits[0] + + refitted = screen.expect( + reconstructions.waveform.limits, + lambda found: abs(found[1] / high - SIXTY_HERTZ / FIFTY_HERTZ) <= REFIT_TOLERANCE, + description="the waveform re-fitted to the longer frames", + ) + assert refitted[0] == low + assert reconstructions.instruments.envelope(ChannelName.NOISE, FeatureKey.VOLUME) == typed[0] + + screen.scenario( + edit_the_line, retune_to_fifty, the_waveform_refits_and_the_edit_stays, leave_letting_the_project_go + ).run() + + +class TestSavingAProjectOpenedAtStart: + """Save writes a project opened at start to its own file and goes through at once. + + A cell is edited so the project is marked unsaved, then Save is pressed. The scenario expects the + saved notice and no file dialog. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: a project opened at start is saved as though it had no file", + ) + def test_save_writes_its_file(self, screen: Screen) -> None: + """Save shows the saved notice and opens no dialog.""" + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(LINE_NUMBER) + screen.expect( + screen.title, titled(screen, marked(ARRANGED_PROJECT.stem, unsaved=True)).__eq__, description="changed" + ) + + screen.press_shortcut(ShortcutId.SAVE_PROJECT) + + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests() == () + screen.expect(screen.project.saved_notice.is_shown, bool, description="the project saved notice") diff --git a/tests/screens/sequencer/song/test_volume_without_a_sample.py b/tests/screens/sequencer/song/test_volume_without_a_sample.py new file mode 100644 index 000000000..2c646c537 --- /dev/null +++ b/tests/screens/sequencer/song/test_volume_without_a_sample.py @@ -0,0 +1,51 @@ +from typing import Final, List, Optional, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK +from tests.screens.sequencer.song.constants import EMPTY_VOICE, SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer + +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None + + +class TestAVolumeWhereNoSamplePlays: + """A volume typed in the Sample column of an empty frame keeps the cell blank, adds no history entry and + creates no pattern. + + An empty second frame is added, a volume is typed on its first row, and the scenario expects the cell, + the history and the order entries of that frame to stay as they were. + """ + + def test_the_frame_stays_empty(self, screen: Screen) -> None: + """The frame stays empty after the volume is typed.""" + order = screen.sequencer.order + tracker = screen.sequencer.tracker + history = screen.sequencer.history + before: List[int] = [] + + def add_an_empty_frame(screen: Screen) -> None: + on_the_sequencer(screen) + order.click(ChannelName.PULSE1, 0) + + screen.press_shortcut(ShortcutId.ORDER_ADD_FRAME) + + screen.expect(order.positions, (2).__eq__, description="a second frame") + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + def type_a_volume_on_it(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 1) + before.append(len(history.lines())) + tracker.click(0, SAMPLE_COLUMN, SubColumn.VOLUME) + + screen.hand.type_text("5") + + screen.frames(SETTLING_FRAMES) + assert tracker.label(0, SAMPLE_COLUMN, SubColumn.VOLUME) == BLANK + assert len(history.lines()) == before[0] + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + screen.scenario(add_an_empty_frame, type_a_volume_on_it, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/tracker/__init__.py b/tests/screens/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/tracker/constants.py b/tests/screens/sequencer/tracker/constants.py new file mode 100644 index 000000000..e91590f8a --- /dev/null +++ b/tests/screens/sequencer/tracker/constants.py @@ -0,0 +1,10 @@ +from typing import Final, Optional + +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A + +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") +TYPING_FRAMES: Final[int] = 10 +LINE_NUMBER: Final[str] = "00" +PAD_NUMBER: Final[str] = "02" +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None diff --git a/tests/screens/sequencer/tracker/steps.py b/tests/screens/sequencer/tracker/steps.py new file mode 100644 index 000000000..dcf1ac6da --- /dev/null +++ b/tests/screens/sequencer/tracker/steps.py @@ -0,0 +1,22 @@ +from typing import Optional + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.constants import TYPING_FRAMES +from tests.suite.screens.screen import Screen + + +def play_a_note(screen: Screen, row: int, channel: ChannelName, key: int) -> None: + """Clicks the transpose slot of a channel at a row, presses a piano key and lets the typing settle.""" + screen.sequencer.tracker.click(row, channel, SubColumn.TRANSPOSE) + screen.hand.press_key(key, modifiers=[]) + screen.frames(TYPING_FRAMES) + + +def type_into(screen: Screen, row: int, channel: Optional[ChannelName], subcolumn: SubColumn, text: str) -> None: + """Clicks a slot of a channel (or of the Sample column, when the channel is None), types the text + and lets the typing settle. + """ + screen.sequencer.tracker.click(row, channel, subcolumn) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py new file mode 100644 index 000000000..443030b16 --- /dev/null +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -0,0 +1,193 @@ +from functools import partial +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_BLANK +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PAD_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from tests.suite.screens.views.tracker import tracker_cell, tracker_cell_theme +from tests.suite.screens.vocabulary.playback import PAUSE +from tests.suite.screens.worlds.songs import PAD_ROW + +PIANO_C_UP: Final[int] = IMGUI_LETTER_A + ord("q") - ord("a") +NOTE_AT_OCTAVE_TWO: Final[str] = "C-2" +NOTE_AT_OCTAVE_THREE: Final[str] = "C-3" +NOTE_AT_OCTAVE_FOUR: Final[str] = "C-4" +HIGHER_OCTAVE: Final[int] = 3 +NO_VOICE_ROW: Final[int] = 2 + + +def history_size(screen: Screen) -> int: + """The number of lines in the Sequencer's history.""" + return len(screen.sequencer.history.lines()) + + +class TestNotesTypedPianoStyle: + """A note key writes the note at the octave in force into a pitch slot whose channel carries a voice. + + The noise column, a voice slot and a row whose channel carries no voice stay blank, and the history + stays as it was for them. + """ + + def test_notes_land_where_a_voice_plays_and_nowhere_else(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + history = screen.sequencer.history + + def a_note_lands_at_the_octave_in_force(screen: Screen) -> None: + on_the_sequencer(screen) + assert tracker.octave() == 2 + before = history_size(screen) + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_TWO + assert tracker.has_caret(PAD_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + 1 + assert history.current().words.endswith(f"t {NOTE_AT_OCTAVE_TWO}") + + def a_new_octave_lands_an_octave_up(screen: Screen) -> None: + tracker.raise_octave() + screen.expect(tracker.octave, HIGHER_OCTAVE.__eq__, description="the octave raised") + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C_UP) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_FOUR + + def the_noise_column_takes_no_note(screen: Screen) -> None: + type_into(screen, NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE, PAD_NUMBER) + assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE) == PAD_NUMBER + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.NOISE, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.TRANSPOSE) == NOTE_BLANK + assert history_size(screen) == before + + def the_voice_slot_takes_no_note(screen: Screen) -> None: + before = history_size(screen) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) == PAD_NUMBER + assert history_size(screen) == before + + def a_row_carrying_no_voice_records_nothing(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_BLANK + assert tracker.has_caret(NO_VOICE_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + + def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, PAD_ROW + 1, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + assert history_size(screen) == before + 1 + + screen.scenario( + a_note_lands_at_the_octave_in_force, + a_new_octave_lands_an_octave_up, + the_noise_column_takes_no_note, + the_voice_slot_takes_no_note, + a_row_carrying_no_voice_records_nothing, + a_note_where_the_voice_plays_on_lands, + leave_letting_the_project_go, + ).run() + + +class TestAVoiceTyped: + """A voice number typed into a slot arrives with its kind's color in the same frame. + + An instrument typed into the Sample column keeps the number and the color as they were. + """ + + def test_the_color_comes_with_the_number(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + typed_row = 9 + + def cell_in_frame(row: int, channel: Optional[ChannelName]) -> Tuple[str, Optional[int]]: + return read_label(tracker_cell(row, channel, SubColumn.VOICE)), tracker_cell_theme( + row, channel, SubColumn.VOICE + ) + + def frames_while_typing(row: int, channel: Optional[ChannelName], text: str) -> List[Tuple[str, Optional[int]]]: + tracker.click(row, channel, SubColumn.VOICE) + with screen.record(partial(cell_in_frame, row, channel)) as recording: + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + return recording.values() + + def an_instrument_arrives_in_its_color(screen: Screen) -> None: + on_the_sequencer(screen) + instrument = tracker.theme(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + frames = frames_while_typing(typed_row, ChannelName.PULSE1, PAD_NUMBER) + + numbered = [theme for label, theme in frames if label == PAD_NUMBER] + assert numbered + assert all(theme == instrument for theme in numbered) + + def a_sample_arrives_in_its_color(screen: Screen) -> None: + sample = screen.sequencer.tracker.theme(0, ChannelName.PULSE1, SubColumn.VOICE) + + frames = frames_while_typing(typed_row + 2, ChannelName.PULSE1, LINE_NUMBER) + + numbered = [theme for label, theme in frames if label == LINE_NUMBER] + assert numbered + assert all(theme == sample for theme in numbered) + + def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: + standing = tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE), tracker.theme( + typed_row, SAMPLE_COLUMN, SubColumn.VOICE + ) + before = history_size(screen) + + frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) + + assert all(frame[1] == standing[1] for frame in frames) + assert all(frame[0] != PAD_NUMBER for frame in frames) + assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] + assert history_size(screen) == before + + screen.scenario( + an_instrument_arrives_in_its_color, + a_sample_arrives_in_its_color, + an_instrument_in_the_sample_column_changes_nothing, + leave_letting_the_project_go, + ).run() + + +class TestPlayingFromATrackerRow: + """Playing the song from the caret's row reads as playing in the Playback menu, Stop answering.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: playing from a tracker row leaves the Playback menu reading Play", + ) + def test_the_menu_follows(self, screen: Screen) -> None: + """After the play-from-row shortcut, the Playback menu offers Pause and Stop answers.""" + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) + + screen.expect(screen.sequencer.playback.can_stop, bool, description="Stop answering") + assert screen.sequencer.playback.play_entry() == screen.words(PAUSE) diff --git a/tests/screens/sequencer/tracker/test_sample_column.py b/tests/screens/sequencer/tracker/test_sample_column.py new file mode 100644 index 000000000..c4839af7f --- /dev/null +++ b/tests/screens/sequencer/tracker/test_sample_column.py @@ -0,0 +1,220 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK, NOTE_OFF +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PAD_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from tests.suite.screens.worlds.songs import BASS_ROW, PAD_ROW + +EMPTY_VOICE: Final[str] = ".." +BASS_NUMBER: Final[str] = "01" +SAMPLE_HEADER: Final[str] = "sequencer.tracker.label.column_sample" +SET_VOICE: Final[str] = "sequencer.tracker.label.context_set_voice" +CLEAR_SUBCOLUMN: Final[str] = "sequencer.tracker.label.context_clear_subcolumn" + +SUBCOLUMN_LETTERS: Final[Tuple[Tuple[SubColumn, str], ...]] = ( + (SubColumn.VOICE, "n"), + (SubColumn.TRANSPOSE, "t"), + (SubColumn.VOLUME, "v"), +) + +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) + + +class TestTheSampleColumn: + """The Sample column reads Sample, offers only samples, and reads a row by the samples its channels play.""" + + def test_its_header_its_menu_and_its_readings(self, screen: Screen) -> None: + """The header, the Set voice menu and the readings of an instrument, a cut and a bass row follow + the column's rule. + """ + tracker = screen.sequencer.tracker + menu = screen.context_menu + cut_row = 6 + instrument_row = 2 + + def the_header_reads_sample(screen: Screen) -> None: + on_the_sequencer(screen) + + assert tracker.header(SAMPLE_COLUMN) == screen.words(SAMPLE_HEADER) + + def set_voice_offers_samples_alone(screen: Screen) -> None: + tracker.right_click(1, SAMPLE_COLUMN, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert [enabled for _, enabled in offered] == [True, True, False] + + def a_channel_cell_offers_every_voice(screen: Screen) -> None: + tracker.right_click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert len(offered) == len(screen.sequencer.voices.names()) + assert all(enabled for _, enabled in offered) + + def an_instrument_alone_reads_empty(screen: Screen) -> None: + type_into(screen, instrument_row, ChannelName.PULSE1, SubColumn.VOICE, PAD_NUMBER) + + assert tracker.label(instrument_row, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER + assert tracker.label(instrument_row, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + + def a_row_cut_everywhere_reads_the_cut(screen: Screen) -> None: + for channel in EVERY_CHANNEL: + type_into(screen, cut_row, channel, SubColumn.VOICE, "-") + + assert all(tracker.label(cut_row, channel, SubColumn.VOICE) == NOTE_OFF for channel in EVERY_CHANNEL) + assert tracker.label(cut_row, SAMPLE_COLUMN, SubColumn.VOICE) == NOTE_OFF + + def the_bass_alone_reads_as_itself(screen: Screen) -> None: + assert screen.sequencer.tracker.label(BASS_ROW, SAMPLE_COLUMN, SubColumn.VOICE) == BASS_NUMBER + + screen.scenario( + the_header_reads_sample, + set_voice_offers_samples_alone, + a_channel_cell_offers_every_voice, + an_instrument_alone_reads_empty, + a_row_cut_everywhere_reads_the_cut, + the_bass_alone_reads_as_itself, + leave_letting_the_project_go, + ).run() + + def test_a_volume_lands_where_the_sample_still_plays(self, screen: Screen) -> None: + """A volume typed into the Sample column reaches the channels that still play the sample. + + A channel cut or given an instrument on its own is left out. + """ + tracker = screen.sequencer.tracker + placed_row = 1 + + def place_the_line_across_its_channels(screen: Screen) -> None: + on_the_sequencer(screen) + + type_into(screen, placed_row, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) + + assert [tracker.label(placed_row, channel, SubColumn.VOICE) for channel in LINE_CHANNELS] == [ + LINE_NUMBER + ] * 3 + assert tracker.label(placed_row, SAMPLE_COLUMN, SubColumn.VOICE) == LINE_NUMBER + + def a_volume_reaches_every_channel_it_plays(screen: Screen) -> None: + type_into(screen, placed_row + 1, SAMPLE_COLUMN, SubColumn.VOLUME, "8") + + assert {channel: tracker.label(placed_row + 1, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "8", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "8", + ChannelName.NOISE: "8", + } + + def a_channel_cut_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 2, ChannelName.NOISE, SubColumn.VOICE, "-") + + type_into(screen, placed_row + 3, SAMPLE_COLUMN, SubColumn.VOLUME, "9") + + assert {channel: tracker.label(placed_row + 3, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "9", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "9", + ChannelName.NOISE: BLANK, + } + + def a_channel_given_an_instrument_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 4, ChannelName.TRIANGLE, SubColumn.VOICE, PAD_NUMBER) + + type_into(screen, placed_row + 5, SAMPLE_COLUMN, SubColumn.VOLUME, "A") + + assert {channel: tracker.label(placed_row + 5, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "A", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: BLANK, + ChannelName.NOISE: BLANK, + } + + screen.scenario( + place_the_line_across_its_channels, + a_volume_reaches_every_channel_it_plays, + a_channel_cut_stops_taking_it, + a_channel_given_an_instrument_stops_taking_it, + leave_letting_the_project_go, + ).run() + + +class TestABlockPastedOnTheSampleColumn: + """A block holding an instrument pasted onto the Sample column lands its samples and leaves the instrument's cells empty.""" + + def test_the_instrument_is_passed_over(self, screen: Screen) -> None: + """The pasted sample appears below the anchor, and no cell takes the instrument's number.""" + tracker = screen.sequencer.tracker + anchor = 9 + + def copy_an_instrument_and_a_sample(screen: Screen) -> None: + on_the_sequencer(screen) + type_into(screen, PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE, LINE_NUMBER) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + tracker.shift_click(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_COPY_BLOCK) + + def paste_it_on_the_sample_column(screen: Screen) -> None: + tracker.click(anchor, SAMPLE_COLUMN, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PASTE_BLOCK) + + screen.expect( + partial(tracker.label, anchor + 1, SAMPLE_COLUMN, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the sample landed", + ) + assert tracker.label(anchor, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + assert all(tracker.label(anchor, channel, SubColumn.VOICE) != PAD_NUMBER for channel in EVERY_CHANNEL) + + screen.scenario( + copy_an_instrument_and_a_sample, paste_it_on_the_sample_column, leave_letting_the_project_go + ).run() + + +class TestHistoryLinesNameTheSlot: + """A history line clearing a slot names it n for the voice, t for the transpose and v for the volume.""" + + def test_each_slot_has_its_letter(self, screen: Screen) -> None: + """Clearing the volume, the transpose and the voice of one cell in turn gives three history lines, + each with its own letter. + """ + tracker = screen.sequencer.tracker + history = screen.sequencer.history + menu = screen.context_menu + letters: List[str] = [] + + def clear_each_slot_of_the_pad(screen: Screen) -> None: + on_the_sequencer(screen) + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.VOLUME, "9") + + for subcolumn, _ in reversed(SUBCOLUMN_LETTERS): + tracker.right_click(PAD_ROW, ChannelName.PULSE2, subcolumn) + screen.expect(menu.is_shown, bool, description="the cell's menu") + menu.choose(screen.words(CLEAR_SUBCOLUMN)) + screen.expect(menu.is_shown, operator.not_, description="the menu answered") + screen.frames(TYPING_FRAMES) + letters.append(history.current().words) + + for line, (_, letter) in zip(letters, reversed(SUBCOLUMN_LETTERS)): + assert letter in line.split() + + assert len(set(letters)) == len(SUBCOLUMN_LETTERS) + + screen.scenario(clear_each_slot_of_the_pad, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/voices/__init__.py b/tests/screens/sequencer/voices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py new file mode 100644 index 000000000..9162f5f90 --- /dev/null +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -0,0 +1,266 @@ +import operator +import re +from functools import partial +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.items.regions import enclosing_regions, read_region_view +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.keyboard import press_combination +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import BY_CONFIGURATION +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go +from tests.suite.screens.views.menus import MenuEntry +from tests.suite.screens.vocabulary.playback import PAUSE +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +POOL_ACTIONS: Final[Tuple[str, ...]] = ( + "sequencer.voices.label.new_instrument", + "sequencer.voices.label.add_sample", + "sequencer.voices.label.import_instrument", +) + +PLAY_VOICE: Final[str] = "global.context.label.play" +STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample" +STATUS_SEPARATOR: Final[str] = "sequencer.voices.template.status_channel_separator" + +LINE_CHANNEL_NAMES: Final[Tuple[str, ...]] = ( + "global.context.label.pulse_1", + "global.context.label.triangle", + "global.context.label.noise", +) + +EMPTY_SPACE_INSET: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 20 +MOVE_DOWN_KEYS: Final[str] = "Alt+Down" +PLACED_ROW: Final[int] = 1 +ADDED_NUMBER: Final[str] = "03" + + +def voice_row(screen: Screen, name: str) -> Item: + """Brings the Sequencer tab to the front and returns the list row of the voice called ``name``.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + return screen.expect_item(partial(screen.sequencer.voices.row, name), description=f"the row of {name}") + + +def right_click_the_empty_list(screen: Screen) -> None: + """Presses the right button over the list, below its rows.""" + view = screen.bridge.ask(lambda: read_region_view(enclosing_regions(TAG_SEQUENCER_VOICES_TABLE)[0])) + assert view is not None + screen.hand.right_click_at( + Point(x=round(view.x + view.width / 2), y=round(view.y + view.height - EMPTY_SPACE_INSET)) + ) + + +def voice_actions(entries: List[str], pool: Tuple[str, ...]) -> List[str]: + """The entries acting on the voice picked, which leaves out the pool's own.""" + return [entry for entry in entries if entry not in pool] + + +def menu_on_the_bar(screen: Screen, group: MenuElements) -> List[MenuEntry]: + """Opens the menu ``group`` with a click, reads what it offers once it settles, and puts it away.""" + screen.menu.open(group) + screen.frames(SETTLING_FRAMES) + entries = screen.menu.entries(group) + screen.menu.close() + screen.frames(SETTLING_FRAMES) + return entries + + +class TestTheVoicesMenus: + """A voice row's menu offers the voice's actions and the pool's; the empty list offers only the pool's. + + The Voice and Edit menus name the voice's actions the row's menu names, Play aside, and the keys + they print are the keys that fire them. + """ + + def test_menus_agree_and_their_keys_fire(self, screen: Screen) -> None: + """The row's menu, the empty list's menu and the two bar menus agree, and the printed Move down keys move the voice.""" + voices = screen.sequencer.voices + menu = screen.context_menu + pool = tuple(screen.words(key) for key in POOL_ACTIONS) + row_actions: List[str] = [] + + def the_row_offers_both(screen: Screen) -> None: + voices.right_click(voice_row(screen, LINE)) + screen.expect(menu.is_shown, bool, description="the row's menu") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + assert tuple(labels[-len(pool) :]) == pool + row_actions.extend(voice_actions(labels, pool)) + + def the_empty_list_offers_the_pool_alone(screen: Screen) -> None: + right_click_the_empty_list(screen) + + screen.expect(menu.is_shown, bool, description="the list's menu") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert tuple(labels) == pool + + def the_bar_menus_name_the_same_actions(screen: Screen) -> None: + voices.pick(voice_row(screen, LINE)) + screen.expect(partial(voices.is_picked, 0), bool, description="the line picked") + + voice_menu = menu_on_the_bar(screen, MenuElements.GROUP_VOICE) + edit_menu = menu_on_the_bar(screen, MenuElements.GROUP_EDIT) + + from_voice = [entry for entry in voice_menu if entry.label in row_actions] + from_edit = [entry for entry in edit_menu if entry.label in row_actions] + assert [entry.label for entry in from_voice] == [entry.label for entry in from_edit] + assert [entry.keys for entry in from_voice] == [entry.keys for entry in from_edit] + assert set(row_actions) - {entry.label for entry in from_voice} == {screen.words(PLAY_VOICE)} + + def the_printed_keys_fire(screen: Screen) -> None: + moves = [ + entry for entry in menu_on_the_bar(screen, MenuElements.GROUP_VOICE) if entry.keys == MOVE_DOWN_KEYS + ] + assert moves + + press_combination(screen.hand, KeyCombination.parse(moves[0].keys)) + + screen.expect(voices.names, [BASS_VOICE, LINE, PAD].__eq__, description="the line moved down") + + screen.scenario( + the_row_offers_both, + the_empty_list_offers_the_pool_alone, + the_bar_menus_name_the_same_actions, + the_printed_keys_fire, + leave_letting_the_project_go, + ).run() + + +class TestHoveringAVoice: + """The status line says what the voice under the pointer is: its name, its kind and the channels it plays.""" + + def test_the_status_names_the_sample(self, screen: Screen) -> None: + """Hovering a sample's row fills the status line with its name and its channels.""" + row = voice_row(screen, LINE) + separator = screen.words(STATUS_SEPARATOR) + channels = separator.join(screen.words(key) for key in LINE_CHANNEL_NAMES) + template = screen.words(STATUS_SAMPLE).format(name=LINE, channels=channels, bytes="{bytes}") + expected = re.compile(re.escape(template).replace(re.escape("{bytes}"), ".+")) + + screen.hand.move_to(Point(x=0, y=0)) + screen.hand.hover(row) + + screen.expect(screen.status, lambda status: expected.fullmatch(status) is not None, description="the status") + forgive_the_hover_race(screen) + + +class TestTheKeysOfACollapsedVoicesCard: + """With the Voices card collapsed, the keys of the voice picked keep the voices as they were; open, they act. + + The line is picked and the card collapsed; Move down and Remove leave the list and the windows as they were. Opening the card makes Move down move the line. + """ + + def test_keys_wait_for_the_card(self, screen: Screen) -> None: + """The keys act once the card is open again.""" + voices = screen.sequencer.voices + + def pick_the_line_and_collapse_the_card(screen: Screen) -> None: + voices.pick(voice_row(screen, LINE)) + screen.expect(partial(voices.is_picked, 0), bool, description="the line picked") + + voices.card.toggle() + + screen.expect(voices.card.is_collapsed, bool, description="the card collapsed") + + def its_keys_change_nothing(screen: Screen) -> None: + press_combination(screen.hand, KeyCombination.parse(MOVE_DOWN_KEYS)) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [LINE, BASS_VOICE, PAD] + assert screen.shown_windows() == () + + def open_they_act(screen: Screen) -> None: + voices.card.toggle() + screen.expect(voices.card.is_collapsed, operator.not_, description="the card open") + voices.pick(voice_row(screen, LINE)) + + press_combination(screen.hand, KeyCombination.parse(MOVE_DOWN_KEYS)) + + screen.expect(voices.names, [BASS_VOICE, LINE, PAD].__eq__, description="the line moved down") + + screen.scenario( + pick_the_line_and_collapse_the_card, + its_keys_change_nothing, + open_they_act, + leave_letting_the_project_go, + ).run() + + +class TestNewInstrumentWithNoProjectOpen: + """New instrument with no project open keeps the voice list empty, and leaving goes through at once.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos § Bugs: New instrument with no project open writes into a project nobody opened", + ) + def test_nothing_is_added(self, screen: Screen) -> None: + """The voice list stays empty and the exit shortcut ends the application.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.project.close() + screen.expect(voices.names, operator.not_, description="no voices listed") + + voices.new_instrument() + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [] + screen.press_shortcut(ShortcutId.EXIT) + assert screen.wait_for_exit() + + +class TestASampleAddedFromTheBrowser: + """A reconstruction double-clicked in the Sequencer's browser joins the voices; placed, the song plays it.""" + + def test_it_joins_and_plays(self, screen: Screen) -> None: + """The voices list a fourth voice after the double click, and playing the song shows Pause once the sample is placed.""" + voices = screen.sequencer.voices + browser = screen.sequencer.browser + tracker = screen.sequencer.tracker + + def double_click_it_in_the_browser(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + heading = screen.expect_item( + partial(browser.heading, screen.words(BY_CONFIGURATION)), description="the heading" + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + row = screen.expect_item(partial(browser.file_row, SHORT_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect(lambda: len(voices.names()), (4).__eq__, description="a fourth voice") + assert voices.names()[:3] == [LINE, BASS_VOICE, PAD] + + def placed_the_song_plays_it(screen: Screen) -> None: + tracker.click(PLACED_ROW, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(ADDED_NUMBER) + screen.expect( + partial(tracker.label, PLACED_ROW, ChannelName.PULSE2, SubColumn.VOICE), + ADDED_NUMBER.__eq__, + description="the sample placed", + ) + + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + screen.press_shortcut(ShortcutId.STOP) + + screen.scenario(double_click_it_in_the_browser, placed_the_song_plays_it, leave_letting_the_project_go).run() diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index 2312ded3b..d3e191231 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -253,7 +253,7 @@ def sounded_period( int: The period the channel holds, within the timer's range. """ moved = reached_note(tuning_table, note_index, table, tick) - period = tuning_table[moved] + instrument.value("toneAdd", tick) + period = tuning_table[moved] + int(instrument.value("toneAdd", tick)) return min(max(period, BITPHASE_SILENT_PERIOD), BITPHASE_MAX_PERIOD) diff --git a/tests/suite/compatibility.py b/tests/suite/compatibility.py index 7c50946b9..d64ae76f3 100644 --- a/tests/suite/compatibility.py +++ b/tests/suite/compatibility.py @@ -1,3 +1,4 @@ +import io import json import zipfile from pathlib import Path @@ -6,6 +7,7 @@ import msgpack from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.data.document import decompress_document from sampletones_tools.compatibility.paths import CORPUS_DIRECTORY, archived_path RECONSTRUCTION_VERSION: Final[str] = "2.1" @@ -30,17 +32,18 @@ def archived(kind: ObjectKind, version: str) -> Path: def stored_document(path: Path) -> Dict[str, Any]: - """What one archived file holds, read without a model so the version can be read first. + """What one stored file holds, read without a model so the version can be read first. A project keeps its document inside an archive, while a reconstruction and a library are the - payload itself, so this answers with the mapping whichever of the two the file is. + payload itself, deflated by the builds that frame their documents, so this answers with the + mapping whichever of these the file is. """ if path.suffix == ".stp": with zipfile.ZipFile(path) as archive: document: Dict[str, Any] = json.loads(archive.read(PROJECT_DOCUMENT)) return document - payload: Dict[str, Any] = msgpack.unpackb(path.read_bytes(), raw=False) + payload: Dict[str, Any] = msgpack.unpackb(decompress_document(path.read_bytes()), raw=False) return payload @@ -62,3 +65,29 @@ def writing_release(path: Path) -> Optional[str]: metadata = document.get(METADATA_FIELD, {}) release = metadata.get(VERSION_FIELD) return release if isinstance(release, str) else None + + +def restated_document(path: Path, kind: ObjectKind, version: str) -> bytes: + """The bytes of the archived file at ``path`` with its stored data version replaced by ``version``. + + Everything else stays as the writing build left it, so a load refusing the result refuses it + for its version alone. + """ + document = stored_document(path) + if kind is ObjectKind.PROJECT: + document[FORMAT_VERSION_FIELD] = version + return _rearchived(path, json.dumps(document).encode()) + + document[METADATA_FIELD][f"{kind.value}_data_version"] = version + packed: bytes = msgpack.packb(document, use_bin_type=True) + return packed + + +def _rearchived(path: Path, project_document: bytes) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(path) as source, zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target: + for member in source.infolist(): + content = project_document if member.filename == PROJECT_DOCUMENT else source.read(member) + target.writestr(member, content) + + return buffer.getvalue() diff --git a/tests/suite/library.py b/tests/suite/library.py index 524514300..7fe821c50 100644 --- a/tests/suite/library.py +++ b/tests/suite/library.py @@ -1,10 +1,17 @@ from pathlib import Path -from typing import Final +from typing import Final, Tuple from sampletones_application.config.managers.config import ConfigManager from sampletones_application.view_model.main.updates import AdvancedSettingsUpdate from sampletones_core.configs import Config -from sampletones_core.library import InstructionLibraryData +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import get_generators_by_channels +from sampletones_core.generators.maps import GENERATOR_TO_INSTRUCTION_MAP +from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from sampletones_core.library.creator.creation import generate_instruction +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_tools.corpus.catalog import CHANNELS, build_mini_library OTHER_LIBRARIES: Final[str] = "other_libraries" @@ -34,3 +41,33 @@ def aim_library_directory(config_manager: ConfigManager, directory: Path) -> Non reconstructions_directory=config_manager.get_reconstructions_directory(), ) ) + + +def build_served_library(config: Config) -> Tuple[InstructionLibrary, InstructionLibraryKey]: + """The mini library for ``config``, holding each generator's default instruction beside its samples. + + A converter run matches against any library, while the Instructions tab opens a generator on + its default instruction, so a library the interface browses holds that one too. + + Returns: + Tuple[InstructionLibrary, InstructionLibraryKey]: The library, and the key it keeps the data under. + """ + library = build_mini_library(config) + window = Window.from_config(config) + key = library.create_key(config, window) + extractor = get_feature_extractor(config, window) + generators = { + generator.class_name(): generator for generator in get_generators_by_channels(config, CHANNELS).values() + } + data = dict(library.data[key].data) + data.update( + generate_instruction( + generators, + class_name, + GENERATOR_TO_INSTRUCTION_MAP[type(generator)].default_instruction(), + extractor, + ) + for class_name, generator in generators.items() + ) + library.data[key] = InstructionLibraryData.create(config, data) + return library, key diff --git a/tests/suite/screens/__init__.py b/tests/suite/screens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/application/__init__.py b/tests/suite/screens/application/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/application/boundaries.py b/tests/suite/screens/application/boundaries.py new file mode 100644 index 000000000..fd9e5777d --- /dev/null +++ b/tests/suite/screens/application/boundaries.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + +from tests.suite.screens.boundaries.audio import OutputRecord +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import TableHighlights +from tests.suite.screens.boundaries.reveals import FileManagerStandIn +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.holds.base import Holds + + +@dataclass(frozen=True) +class Boundaries: + """What stands between a scenario's application and the desktop around it.""" + + dialogs: ScriptedFileDialogs + errors: ErrorRecords + spawns: SpawnGuard + holds: Holds + file_manager: FileManagerStandIn + highlights: TableHighlights + output: OutputRecord diff --git a/tests/suite/screens/application/running.py b/tests/suite/screens/application/running.py new file mode 100644 index 000000000..1f819d0ef --- /dev/null +++ b/tests/suite/screens/application/running.py @@ -0,0 +1,168 @@ +import threading +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg + +from sampletones_application.application import Application +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.utils.gui.keyboard import KeyEvent +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager +from tests.suite.screens.application.boundaries import Boundaries +from tests.suite.screens.checks import ( + AfterCheckError, + contained_findings, + quiet_findings, + read_screen_state, + screen_findings, + surviving_thread_findings, +) +from tests.suite.screens.dearpygui.bridge import Bridge, RenderThreadStoppedError +from tests.suite.screens.dearpygui.hosting import SCENARIO_THREAD_NAME +from tests.suite.screens.dearpygui.screenshot import capture +from tests.suite.screens.keyboard import primary_combination +from tests.suite.screens.render_thread import QueueRenderThread + +EXIT_TIMEOUT_SECONDS: Final[float] = 15.0 + + +class ScreenApplication: + """SampleToNES running for one scenario: its loop on the main thread, its ending read by the after-checks. + + A scenario that ends well leaves through the Exit shortcut, so every question the application asks + about unsaved work runs as it would for a user, and the application must then stop at once. A + scenario that failed, or a screen the after-checks object to, stops the loop outright once a + screenshot of it is kept. Either way, the work the scenario held is let go first. + """ + + def __init__( + self, + application: Application, + *, + render_thread: QueueRenderThread, + bridge: Bridge, + boundaries: Boundaries, + failure_screenshot: Path, + state_path: Path, + ) -> None: + self._application = application + self._render_thread = render_thread + self._bridge = bridge + self._boundaries = boundaries + self._failure_screenshot = failure_screenshot + self._state_path = state_path + self._reported_errors = 0 + + @property + def language(self) -> LanguageManager: + """The application's language manager, which turns a language key into the words a user reads.""" + return self._application.language_manager + + @property + def shortcuts(self) -> ShortcutManager: + """The application's shortcut manager, which says what keys each action has under the scheme in + place. + """ + return self._application.shortcut_manager + + def run(self) -> None: + """Starts the render thread and runs the application's loop on the calling thread until the loop + ends. + """ + self._boundaries.spawns.arm() + self._render_thread.start() + try: + self._application.run() + finally: + self._render_thread.stop() + + def finish(self, *, failed: bool) -> None: + """Ends the scenario: lets go of its holds, reads the after-checks and stops the application. + + A scenario that ended well leaves through the Exit shortcut; a failed one, or a screen the + after-checks object to, stops the loop outright after a screenshot is kept. + + Raises: + AfterCheckError: If the application broke a promise every scenario holds it to. + """ + self._boundaries.holds.release_all() + findings = self._boundary_findings() + if self._render_thread.is_running(): + findings.extend(self._screen_findings()) + if failed or findings: + findings.extend(self._capture_failure()) + self._halt() + elif not self._exit(): + findings.append("Leaving the application asked a question the scenario never answered") + findings.extend(self._capture_failure()) + self._halt() + + if findings: + raise AfterCheckError("\n".join(findings)) + + def close(self) -> None: + """Reads the checks that apply once the application has stopped. + + They cover late errors, surviving threads and the session state the application writes. + + Raises: + AfterCheckError: If an error came late, a thread outlived the exit or the session state is + missing. + """ + findings = quiet_findings(self._boundaries.errors, self._reported_errors, None) + findings.extend(surviving_thread_findings(_application_threads())) + if not self._state_path.exists(): + findings.append(f"The application stopped without writing its session state to {self._state_path}") + + if findings: + raise AfterCheckError("\n".join(findings)) + + def _boundary_findings(self) -> List[str]: + self._reported_errors = self._boundaries.errors.count + quiet = quiet_findings(self._boundaries.errors, 0, self._reported_errors) + return [ + *quiet, + *contained_findings(self._boundaries.spawns, self._boundaries.dialogs), + ] + + def _screen_findings(self) -> List[str]: + try: + state = self._bridge.ask(read_screen_state) + except RenderThreadStoppedError: + return [] + + return screen_findings(state) + + def _exit(self) -> bool: + """Presses the Exit shortcut and says whether the application stopped within the exit's time.""" + combination = primary_combination(self._application.shortcut_manager.shortcut(ShortcutId.EXIT)) + event = KeyEvent(key=combination.key, modifiers=combination.modifiers) + self._bridge.ask(lambda: self._application.key_router.route(event)) + return self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) + + def _halt(self) -> None: + """Stops the loop outright and waits for it to end.""" + try: + self._bridge.ask(dpg.stop_dearpygui) + except RenderThreadStoppedError: + return + + self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) + + def _capture_failure(self) -> List[str]: + try: + capture(self._bridge, self._failure_screenshot) + except AssertionError as error: + return [f"The failure screenshot could not be taken: {error}"] + + return [] + + +def _application_threads() -> List[threading.Thread]: + """The threads running beside the main one, the scenario's own aside.""" + return [ + thread + for thread in threading.enumerate() + if thread is not threading.main_thread() and thread.name != SCENARIO_THREAD_NAME + ] diff --git a/tests/suite/screens/application/startup.py b/tests/suite/screens/application/startup.py new file mode 100644 index 000000000..fe2f0363f --- /dev/null +++ b/tests/suite/screens/application/startup.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class Startup: + """The documents a scenario's application opens as it starts, as ``sampletones open`` hands them over. + + Attributes: + reconstruction: The reconstruction open on the Reconstructions tab, if any. + project: The project open in the Sequencer, if any. + """ + + reconstruction: Optional[Path] + project: Optional[Path] diff --git a/tests/suite/screens/boundaries/__init__.py b/tests/suite/screens/boundaries/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/boundaries/audio.py b/tests/suite/screens/boundaries/audio.py new file mode 100644 index 000000000..68273d9a9 --- /dev/null +++ b/tests/suite/screens/boundaries/audio.py @@ -0,0 +1,91 @@ +import threading +from enum import StrEnum +from pathlib import Path +from typing import Final, Optional + +import numpy as np +import pyaudio +import pytest + +ALSA_CONFIGURATION_FILE: Final[str] = ".asoundrc" +SILENT_DEFAULT_DEVICE: Final[str] = "pcm.!default {\n type null\n}\n" + + +class OutputDevice(StrEnum): + """The output a scenario's machine offers: a device that plays into silence, or none at all.""" + + SILENT = "silent" + NONE = "none" + + +class OutputRecord: + """What the application wrote to its output device: how many of the samples it played carry sound. + + It wraps the write of every output stream, so a sound counts once it reaches the device, however briefly it + plays and however many frames pass between two readings of it. The playback thread writes while a + scenario reads, so the count stands behind a lock. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sounding_samples = 0 + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Puts the record in front of every output stream's write before the application starts. + + ``PyAudio.open`` builds its streams from ``PyAudio.Stream``, the class whose write is wrapped. + """ + write = pyaudio.PyAudio.Stream.write + + def recorded( + stream: pyaudio.PyAudio.Stream, + frames: bytes, + num_frames: Optional[int] = None, + exception_on_underflow: bool = False, + ) -> None: + self._count(frames) + write(stream, frames, num_frames, exception_on_underflow) + + monkeypatch.setattr(pyaudio.PyAudio.Stream, "write", recorded) + + def sounding_samples(self) -> int: + """How many samples carrying sound the application has written to its output so far.""" + with self._lock: + return self._sounding_samples + + def _count(self, frames: bytes) -> None: + sounding = int(np.count_nonzero(np.frombuffer(frames, dtype=np.float32))) + with self._lock: + self._sounding_samples += sounding + + +def provide_output_device( + device: OutputDevice, + *, + home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Gives the application the output ``device`` before it starts.""" + match device: + case OutputDevice.SILENT: + _silent_output_device(home) + case OutputDevice.NONE: + _no_output_device(monkeypatch) + + +def _silent_output_device(home: Path) -> None: + """Points ALSA's default device at a sink that consumes sound in real time and plays none of it. + + Playback runs as it does for a user, its stream and its cursor included, and nothing is heard. + """ + (home / ALSA_CONFIGURATION_FILE).write_text(SILENT_DEFAULT_DEVICE, encoding="utf-8") + + +def _no_output_device(monkeypatch: pytest.MonkeyPatch) -> None: + """Starts the application on a machine offering no output device at all.""" + monkeypatch.setattr(pyaudio.PyAudio, "get_device_count", lambda _: 0) + monkeypatch.setattr(pyaudio.PyAudio, "get_default_output_device_info", _no_default_device) + + +def _no_default_device(_: pyaudio.PyAudio) -> None: + raise OSError("A screen scenario offers no output device") diff --git a/tests/suite/screens/boundaries/dialogs.py b/tests/suite/screens/boundaries/dialogs.py new file mode 100644 index 000000000..f10f4ad3c --- /dev/null +++ b/tests/suite/screens/boundaries/dialogs.py @@ -0,0 +1,216 @@ +import threading +from collections import deque +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Deque, Dict, List, Optional, Tuple + +import pytest + +import sampletones_application.utils.file_dialogs.api as file_dialogs_api +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +class DialogKind(StrEnum): + """What a file dialog asks the user for: a file to open, a path to save to or a folder.""" + + OPEN = "open" + SAVE = "save" + DIRECTORY = "directory" + + +@dataclass(frozen=True) +class DialogAnswer: + """What a scenario queued for a dialog to answer: a path, and for a save dialog the type picked in its + selector. + + Attributes: + path: The path the dialog answers with. + type_name: The name of the offered file type to pick, or ``None`` to leave the selector as it was. + """ + + path: Path + type_name: Optional[str] + + +@dataclass(frozen=True) +class DialogRequest: + """One file dialog the application opened during a scenario. + + Attributes: + kind: What the dialog asked for. + title: The title the dialog carried. + initial_directory: The folder the dialog opened in, if the application named one. + suggested_name: The file name a save dialog offered, if any. + filters: The kinds of file the dialog offered to show. + answered: Whether the scenario had queued an answer for it. + answer: The path the dialog answered with, or ``None`` for a dismissed dialog. + """ + + kind: DialogKind + title: str + initial_directory: Optional[Path] + suggested_name: Optional[str] + filters: Tuple[FileFilter, ...] + answered: bool + answer: Optional[Path] + + +class ScriptedFileDialogs: + """The file dialogs a scenario stands behind: each answers with what the scenario queued for its kind. + + A native dialog stops the queue the render thread drains while it stands, so a dialog here answers + at once from the answers queued before the gesture that opens it. A dialog opening with nothing + queued is dismissed, and the after-checks report it as a question the scenario left unanswered. + """ + + def __init__(self) -> None: + self._answers: Dict[DialogKind, Deque[Optional[DialogAnswer]]] = {kind: deque() for kind in DialogKind} + self._requests: List[DialogRequest] = [] + self._lock = threading.Lock() + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Stands in for the native backend every file dialog of the application asks for.""" + monkeypatch.setattr(file_dialogs_api, "select_file_dialog_backend", lambda: self) + + def answer( + self, + kind: DialogKind, + path: Optional[Path], + ) -> None: + """Queues the answer the next dialog of ``kind`` gives: ``path``, or ``None`` to dismiss it.""" + with self._lock: + self._answers[kind].append(None if path is None else DialogAnswer(path=path, type_name=None)) + + def answer_save_as( + self, + path: Path, + type_name: str, + ) -> None: + """Queues the answer the next save dialog gives: ``path``, with the type named ``type_name`` picked. + + A dialog offering a type of another name stays unanswered, which the after-checks report. + """ + with self._lock: + self._answers[DialogKind.SAVE].append(DialogAnswer(path=path, type_name=type_name)) + + @property + def requests(self) -> Tuple[DialogRequest, ...]: + """Every dialog the application opened so far, in order, answered or not.""" + with self._lock: + return tuple(self._requests) + + @property + def unanswered(self) -> Tuple[DialogRequest, ...]: + """The dialogs that opened while no answer was queued for them.""" + return tuple(request for request in self.requests if not request.answered) + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + """Answers an open dialog with the queued path, or ``None`` when it is dismissed.""" + return _path( + self._take( + DialogKind.OPEN, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=filters, + ) + ) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + """Answers a save dialog with the queued path and the file type picked, or ``None`` when it is + dismissed. + """ + answer = self._take( + DialogKind.SAVE, + title=title, + initial_directory=initial_directory, + suggested_name=suggested_name, + filters=filters, + ) + if answer is None: + return None + + return SaveDestination(path=answer.path, file_type=_picked(answer, filters)) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + """Answers a folder dialog with the queued path, or ``None`` when it is dismissed.""" + return _path( + self._take( + DialogKind.DIRECTORY, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=(), + ) + ) + + def _take( + self, + kind: DialogKind, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[DialogAnswer]: + """Takes the answer queued for ``kind``, notes the request and returns the answer, if any.""" + with self._lock: + queued = self._answers[kind] + answered = bool(queued) + answer = queued.popleft() if answered else None + if answer is not None and answer.type_name is not None and not _offers(filters, answer.type_name): + answered = False + answer = None + + self._requests.append( + DialogRequest( + kind=kind, + title=title, + initial_directory=initial_directory, + suggested_name=suggested_name, + filters=filters, + answered=answered, + answer=_path(answer), + ) + ) + + return answer + + +def _path(answer: Optional[DialogAnswer]) -> Optional[Path]: + return None if answer is None else answer.path + + +def _offers( + filters: Tuple[FileFilter, ...], + type_name: str, +) -> bool: + return any(file_filter.name == type_name for file_filter in filters) + + +def _picked( + answer: DialogAnswer, + filters: Tuple[FileFilter, ...], +) -> Optional[FileFilter]: + """The offered type the answer picks, or ``None`` for an answer leaving the selector alone.""" + return next((file_filter for file_filter in filters if file_filter.name == answer.type_name), None) diff --git a/tests/suite/screens/boundaries/errors.py b/tests/suite/screens/boundaries/errors.py new file mode 100644 index 000000000..71061af77 --- /dev/null +++ b/tests/suite/screens/boundaries/errors.py @@ -0,0 +1,98 @@ +import inspect +import logging +import threading +from dataclasses import dataclass +from types import CodeType +from typing import Final, List, Optional, Tuple + +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_shared.application import SAMPLETONES_NAME + +EXIT_JOIN_CODE: Final[CodeType] = SingleThreadExecutor.join_all.__code__ + + +@dataclass +class _ErrorRecord: + message: str + claimed: bool + + +class ErrorRecords(logging.Handler): + """Every error the application logs, and every exception one of its threads lets escape. + + A gesture's callback that raises is logged and swallowed so the interface keeps running, which + is why a scenario reads this record: a click that failed this way leaves the screen looking calm. + The warning that background work outlived the exit's deadline counts as an error too, since the + teardown destroys what that work still reaches. + + A scenario that provokes a failure claims the error it expects, and every error left unclaimed + is one the application reported on its own. + """ + + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self._records: List[_ErrorRecord] = [] + self._lock = threading.Lock() + + def install(self) -> None: + """Starts recording what the application logs and what its threads let escape.""" + logging.getLogger(SAMPLETONES_NAME).addHandler(self) + threading.excepthook = self._escaped + + @property + def count(self) -> int: + """How many errors have been recorded, claimed or not.""" + with self._lock: + return len(self._records) + + def unclaimed( + self, + start: int, + stop: Optional[int], + ) -> Tuple[str, ...]: + """The errors recorded from the ``start``-th up to the ``stop``-th, or on, that no scenario claimed.""" + with self._lock: + return tuple(record.message for record in self._records[start:stop] if not record.claimed) + + def claim(self, naming: str) -> bool: + """Takes the first unclaimed error whose message holds ``naming`` as provoked, and says whether one + did. + """ + with self._lock: + for record in self._records: + if not record.claimed and naming in record.message: + record.claimed = True + return True + + return False + + def emit(self, record: logging.LogRecord) -> None: + """Records a logged error, and a warning logged from within the exit's join on background work.""" + if record.levelno < logging.ERROR and not _logged_by_the_exit_join(): + return + + self._record(f"{record.levelname} {self.format(record)}") + + def _escaped(self, arguments: threading.ExceptHookArgs) -> None: + thread = arguments.thread.name if arguments.thread is not None else "a thread" + self._record(f"{thread} let {arguments.exc_type.__name__} escape: {arguments.exc_value}") + + def _record(self, message: str) -> None: + with self._lock: + self._records.append(_ErrorRecord(message=message, claimed=False)) + + +def _logged_by_the_exit_join() -> bool: + """Whether the record being handled was logged from within the join that awaits background work. + + The application logs through a wrapper, so a record names the wrapper as its origin, and the + call stack is what still holds the join. + """ + frame = inspect.currentframe() + while frame is not None: + if frame.f_code is EXIT_JOIN_CODE: + return True + + frame = frame.f_back + + return False diff --git a/tests/suite/screens/boundaries/highlights.py b/tests/suite/screens/boundaries/highlights.py new file mode 100644 index 000000000..2ed2be267 --- /dev/null +++ b/tests/suite/screens/boundaries/highlights.py @@ -0,0 +1,113 @@ +import threading +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, Final, Optional, Sequence, Tuple, Union + +import dearpygui.dearpygui as dpg +import pytest + +TableItem = Union[int, str] +Color = Tuple[float, ...] +WHOLE_LINE: Final[None] = None + + +class HighlightKind(StrEnum): + """The stretch of a table one highlight covers.""" + + ROW = "row" + CELL = "cell" + COLUMN = "column" + + +@dataclass(frozen=True) +class HighlightPlace: + """Where a table highlight stands: the table, the stretch it covers, and the row and the column it covers. + + Attributes: + table: The table's tag. + kind: Whether the highlight covers a row, a cell or a column. + row: The row it covers, or ``None`` for a column. + column: The column it covers, or ``None`` for a row. + """ + + table: str + kind: HighlightKind + row: Optional[int] + column: Optional[int] + + +class TableHighlights: + """Notes every highlight the application lays on a table and lifts from one, with its color. + + DearPyGui holds a table's row, cell and column highlights as the table's own state, and answers + whether one stands but never its color. The stand-in wraps the calls that lay and lift them, + which run as they did, so the highlights standing and their colors are known as the + application left them, from the first frame on. + """ + + def __init__(self) -> None: + self._standing: Dict[HighlightPlace, Color] = {} + self._lock = threading.Lock() + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Wraps DearPyGui's calls that lay and lift table highlights, so each one is noted as it runs.""" + lay_row = dpg.highlight_table_row + lay_cell = dpg.highlight_table_cell + lay_column = dpg.highlight_table_column + lift_row = dpg.unhighlight_table_row + lift_cell = dpg.unhighlight_table_cell + lift_column = dpg.unhighlight_table_column + + def highlight_row(table: TableItem, row: int, color: Sequence[float]) -> None: + lay_row(table, row, color) + self._lay(_place(table, HighlightKind.ROW, row, WHOLE_LINE), color) + + def highlight_cell(table: TableItem, row: int, column: int, color: Sequence[float]) -> None: + lay_cell(table, row, column, color) + self._lay(_place(table, HighlightKind.CELL, row, column), color) + + def highlight_column(table: TableItem, column: int, color: Sequence[float]) -> None: + lay_column(table, column, color) + self._lay(_place(table, HighlightKind.COLUMN, WHOLE_LINE, column), color) + + def unhighlight_row(table: TableItem, row: int) -> None: + lift_row(table, row) + self._lift(_place(table, HighlightKind.ROW, row, WHOLE_LINE)) + + def unhighlight_cell(table: TableItem, row: int, column: int) -> None: + lift_cell(table, row, column) + self._lift(_place(table, HighlightKind.CELL, row, column)) + + def unhighlight_column(table: TableItem, column: int) -> None: + lift_column(table, column) + self._lift(_place(table, HighlightKind.COLUMN, WHOLE_LINE, column)) + + monkeypatch.setattr(dpg, "highlight_table_row", highlight_row) + monkeypatch.setattr(dpg, "highlight_table_cell", highlight_cell) + monkeypatch.setattr(dpg, "highlight_table_column", highlight_column) + monkeypatch.setattr(dpg, "unhighlight_table_row", unhighlight_row) + monkeypatch.setattr(dpg, "unhighlight_table_cell", unhighlight_cell) + monkeypatch.setattr(dpg, "unhighlight_table_column", unhighlight_column) + + def standing(self) -> Dict[HighlightPlace, Color]: + """Every highlight standing, with the color it was laid in.""" + with self._lock: + return dict(self._standing) + + def _lay(self, place: HighlightPlace, color: Sequence[float]) -> None: + with self._lock: + self._standing[place] = tuple(float(part) for part in color) + + def _lift(self, place: HighlightPlace) -> None: + with self._lock: + self._standing.pop(place, None) + + +def _place( + table: TableItem, + kind: HighlightKind, + row: Optional[int], + column: Optional[int], +) -> HighlightPlace: + """The place a call names, its table known by its tag. Runs on the render thread.""" + return HighlightPlace(table=str(dpg.get_item_alias(table) or table), kind=kind, row=row, column=column) diff --git a/tests/suite/screens/boundaries/reveals.py b/tests/suite/screens/boundaries/reveals.py new file mode 100644 index 000000000..5a6f60ae1 --- /dev/null +++ b/tests/suite/screens/boundaries/reveals.py @@ -0,0 +1,55 @@ +import os +import stat +from pathlib import Path +from typing import Final, Mapping, Tuple + +import pytest + +PATH_VARIABLE: Final[str] = "PATH" +FILE_MANAGER: Final[str] = "nemo" +FILE_MANAGER_ENTRY: Final[str] = f"{FILE_MANAGER}.desktop" +EXECUTABLE: Final[int] = stat.S_IRWXU + + +class FileManagerStandIn: + """Stands in for the desktop's file manager, noting every path the application asks it to show. + + The application asks ``xdg-mime`` which file manager the desktop uses and then asks that file + manager, or ``xdg-open``, to show a path. Programs of those names lie in ``folder``, which leads + the search path, so the application's own reveal code runs and reaches them: the first names a + file manager they stand for, and the others write the path they were given to ``log``, one line + each. + """ + + def __init__( + self, + folder: Path, + log: Path, + ) -> None: + self.folder = folder + self._log = log + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Lays the programs in the folder and puts the folder first on the search path.""" + self.folder.mkdir(parents=True, exist_ok=True) + for name, script in self._programs().items(): + program = self.folder / name + program.write_text(script) + program.chmod(EXECUTABLE) + + monkeypatch.setenv(PATH_VARIABLE, os.pathsep.join((str(self.folder), os.environ[PATH_VARIABLE]))) + + def shown(self) -> Tuple[Path, ...]: + """Every path the file manager was asked to show, in order.""" + if not self._log.exists(): + return () + + return tuple(Path(line) for line in self._log.read_text().splitlines()) + + def _programs(self) -> Mapping[str, str]: + noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._log}"; done\n' + return { + "xdg-mime": f"#!/bin/sh\necho {FILE_MANAGER_ENTRY}\n", + FILE_MANAGER: noting, + "xdg-open": noting, + } diff --git a/tests/suite/screens/boundaries/spawns.py b/tests/suite/screens/boundaries/spawns.py new file mode 100644 index 000000000..8156b6cfa --- /dev/null +++ b/tests/suite/screens/boundaries/spawns.py @@ -0,0 +1,94 @@ +import shutil +import sys +import threading +from pathlib import Path +from typing import Any, Final, FrozenSet, List, Optional, Tuple + +LIBRARY_PROBES: Final[FrozenSet[str]] = frozenset({"cc", "gcc", "ld", "ldconfig", "objdump"}) +PROGRAM_ARGUMENT: Final[int] = 0 +ARGUMENTS_ARGUMENT: Final[int] = 1 + +SPAWN_EVENTS: Final[FrozenSet[str]] = frozenset( + { + "os.exec", + "os.posix_spawn", + "os.spawn", + "os.startfile", + "os.system", + "subprocess.Popen", + "webbrowser.open", + } +) + + +class SpawnRefusedError(PermissionError): + """Raised inside the application when it tries to start a program during a scenario.""" + + +class SpawnGuard: + """Refuses every program the application tries to start once a scenario runs, and remembers each try. + + A file manager, a browser or a dialog tool would open on the desktop around the scenario's display. + The guard is an audit hook, so it stands in front of every way Python starts a program, however + a module imported it. It lets through the application's own worker processes, which start through + multiprocessing, the tools ``ctypes.util.find_library`` asks where a shared library lies, and the + stand-in programs of the folder it is told to allow. A refused try is remembered by its program and + arguments alone, since the environment it carried can hold the secrets of the machine the run is on. + """ + + def __init__(self) -> None: + self._refused: List[str] = [] + self._armed = False + self._allowed: Optional[Path] = None + self._lock = threading.Lock() + + def install(self) -> None: + """Puts the guard in front of the process for good; it lets every try through until armed.""" + sys.addaudithook(self._audit) + + def allow(self, folder: Path) -> None: + """Lets the programs lying in ``folder`` start, which is where the scenario's stand-ins lie.""" + self._allowed = folder + + def arm(self) -> None: + """Starts refusing the programs the application tries to start.""" + self._armed = True + + @property + def refused(self) -> Tuple[str, ...]: + """The programs refused so far, each with its event and arguments, in order.""" + with self._lock: + return tuple(self._refused) + + def _audit( + self, + event: str, + arguments: Tuple[Any, ...], + ) -> None: + if not self._armed or event not in SPAWN_EVENTS: + return + + program = Path(str(arguments[PROGRAM_ARGUMENT])).name if arguments else "" + if program in LIBRARY_PROBES or self._is_allowed(arguments): + return + + attempt = f"{event} {program} {_described(arguments)}" + with self._lock: + self._refused.append(attempt) + + raise SpawnRefusedError(f"A screen scenario refuses to start a program: {attempt}") + + def _is_allowed(self, arguments: Tuple[Any, ...]) -> bool: + """Whether the program a try names lies in the folder of stand-ins the guard allows.""" + if self._allowed is None or not arguments: + return False + + located = shutil.which(str(arguments[PROGRAM_ARGUMENT])) + return located is not None and Path(located).parent == self._allowed + + +def _described(arguments: Tuple[Any, ...]) -> str: + if len(arguments) > ARGUMENTS_ARGUMENT: + return repr(arguments[ARGUMENTS_ARGUMENT]) + + return "" diff --git a/tests/suite/screens/checks.py b/tests/suite/screens/checks.py new file mode 100644 index 000000000..b90410dad --- /dev/null +++ b/tests/suite/screens/checks.py @@ -0,0 +1,79 @@ +import threading +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_WINDOW_MAIN +from sampletones_application.utils.gui.modal_queue import ModalQueue, ModalQueueSnapshot +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import WindowReading, read_windows +from tests.suite.screens.dearpygui.items.viewport import read_client_area + + +class AfterCheckError(AssertionError): + """Raised when a scenario's application broke a promise every scenario holds it to.""" + + +@dataclass(frozen=True) +class ScreenState: + """What the screen holds as a scenario ends: its windows, the modal line, and the client area.""" + + windows: Tuple[WindowReading, ...] + modal_line: ModalQueueSnapshot + client_area: Rect + + +def read_screen_state() -> ScreenState: + """Reads the screen as a scenario ends. Runs on the render thread.""" + return ScreenState( + windows=read_windows(), + modal_line=ModalQueue.snapshot(), + client_area=read_client_area(), + ) + + +def quiet_findings( + errors: ErrorRecords, + start: int, + stop: Optional[int], +) -> List[str]: + """The unprovoked errors the application logged or let escape a thread, from the ``start``-th to the + ``stop``-th. + """ + return [f"The application reported: {message}" for message in errors.unclaimed(start, stop)] + + +def surviving_thread_findings(threads: Sequence[threading.Thread]) -> List[str]: + """The threads still running once the application has stopped and torn its context down.""" + return [f"The thread '{thread.name}' outlived the application's exit" for thread in threads] + + +def contained_findings( + spawns: SpawnGuard, + dialogs: ScriptedFileDialogs, +) -> List[str]: + """The programs the application tried to start, and the dialogs it opened with no answer queued.""" + return [f"The application tried to start a program: {attempt}" for attempt in spawns.refused] + [ + f"The {request.kind} file dialog '{request.title}' opened with no answer queued" + for request in dialogs.unanswered + ] + + +def screen_findings(state: ScreenState) -> List[str]: + """What the screen still shows as a scenario ends: open conversations, error dialogs, windows off it.""" + findings: List[str] = [] + if state.modal_line.shown is not None or not state.modal_line.is_settled: + findings.append(f"The scenario ended with a modal conversation still open: {state.modal_line}") + + for window in state.windows: + if not window.shown or window.alias == TAG_GLOBAL_WINDOW_MAIN: + continue + if is_tag_within(window.alias, TAG_GLOBAL_DIALOG_ERROR): + findings.append(f"An error dialog stands on the screen: '{window.label}'") + if not state.client_area.contains(window.rect): + findings.append(f"The window '{window.alias}' reaches outside the viewport: {window.rect}") + + return findings diff --git a/tests/suite/screens/dearpygui/__init__.py b/tests/suite/screens/dearpygui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/dearpygui/bridge.py b/tests/suite/screens/dearpygui/bridge.py new file mode 100644 index 000000000..e32c94211 --- /dev/null +++ b/tests/suite/screens/dearpygui/bridge.py @@ -0,0 +1,172 @@ +import math +import threading +import time +from concurrent.futures import Future +from typing import Callable, Final, Protocol, TypeVar + +AnswerT = TypeVar("AnswerT") +NEXT_DRAIN: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +POLL_SECONDS: Final[float] = 0.05 +REFERENCE_FRAME_RATE: Final[int] = 30 + + +class RenderThreadTimeoutError(AssertionError): + """Raised when the render thread leaves a question unanswered while it still runs.""" + + +class RenderThreadStoppedError(AssertionError): + """Raised when the application stops drawing before it answers a scenario's question.""" + + +class ExpectationError(AssertionError): + """Raised when what a scenario expects never holds within its time.""" + + +class RenderThread(Protocol): + """The thread an application's DearPyGui context belongs to, reached from a scenario's own thread. + + The application under test says how work reaches it, which is the one thing this layer asks of + the application. + """ + + def post( + self, + task: Callable[[], None], + *, + frames: int, + ) -> None: + """Runs ``task`` on the render thread once ``frames`` more frames have been drawn.""" + + def is_running(self) -> bool: + """Whether the render thread still draws frames, and so still runs what is posted to it.""" + + def frames_drawn(self) -> int: + """How many frames the render thread has drawn, read from any thread.""" + + +class Deadline: + """The end of a wait, which comes once its seconds have run and the render thread has drawn its frames. + + The frames are those a machine drawing ``REFERENCE_FRAME_RATE`` frames a second draws in the same seconds. A + machine drawing more slowly answers each gesture in more seconds, so its waits stretch with its frames, and a + wait fails on what the application did within them. A render thread that stops ends every wait. + """ + + def __init__( + self, + render_thread: RenderThread, + *, + seconds: float, + ) -> None: + self._render_thread = render_thread + self.seconds = seconds + self.frames = math.ceil(seconds * REFERENCE_FRAME_RATE) + self._end = time.monotonic() + seconds + self._last_frame = render_thread.frames_drawn() + self.frames + + def passed(self) -> bool: + """Whether both the seconds and the frames of the wait have run, or the render thread stopped.""" + if not self._render_thread.is_running(): + return True + + return time.monotonic() >= self._end and self._render_thread.frames_drawn() >= self._last_frame + + +class Bridge: + """The crossing a scenario takes to the render thread, where every reading and gesture happens. + + A question runs between two frames, so what it reads is one consistent frame. Its answer, or the + exception it raised, comes back to the scenario's thread. Waiting is counted in frames drawn, and + an expectation is read once a frame until it holds or its `Deadline` passes. + """ + + def __init__( + self, + render_thread: RenderThread, + *, + answer_timeout: float, + ) -> None: + self._render_thread = render_thread + self._answer_timeout = answer_timeout + + def ask(self, question: Callable[[], AnswerT]) -> AnswerT: + """Runs ``question`` on the render thread and returns what it answered. + + Raises: + RenderThreadTimeoutError: If the render thread leaves the question unanswered. + RenderThreadStoppedError: If the application stops before answering. + """ + answer: Future[AnswerT] = Future() + answered = threading.Event() + + def task() -> None: + try: + answer.set_result(question()) + except BaseException as error: # pylint: disable=broad-exception-caught + answer.set_exception(error) + finally: + answered.set() + + self._render_thread.post(task, frames=NEXT_DRAIN) + self._wait(answered, what="a question") + return answer.result() + + def frames(self, count: int) -> None: + """Waits until ``count`` more frames have been drawn. + + Raises: + RenderThreadTimeoutError: If the frames stop arriving. + RenderThreadStoppedError: If the application stops before they are drawn. + """ + drawn = threading.Event() + self._render_thread.post(drawn.set, frames=count) + self._wait(drawn, what=f"{count} frames") + + def expect( + self, + reading: Callable[[], AnswerT], + holds: Callable[[AnswerT], bool], + *, + description: str, + timeout: float, + ) -> AnswerT: + """Takes ``reading`` once a frame until ``holds`` accepts it, and returns that reading. + + ``reading`` runs on the caller's thread, so it asks the render thread for what it reads and + may combine several answers. + + Raises: + ExpectationError: If no reading holds before the deadline of ``timeout`` seconds passes, naming the + last one. + """ + deadline = self.deadline(timeout) + while True: + value = reading() + if holds(value): + return value + + if deadline.passed(): + raise ExpectationError( + f"Expected {description} within {timeout} s and {deadline.frames} frames; " + f"the last reading was {value!r}" + ) + + self.frames(ONE_FRAME) + + def deadline(self, seconds: float) -> Deadline: + """The deadline of a wait of ``seconds`` that starts now.""" + return Deadline(self._render_thread, seconds=seconds) + + def _wait( + self, + event: threading.Event, + *, + what: str, + ) -> None: + deadline = time.monotonic() + self._answer_timeout + while not event.wait(POLL_SECONDS): + if not self._render_thread.is_running() and not event.is_set(): + raise RenderThreadStoppedError(f"The application stopped before {what} came back") + if time.monotonic() >= deadline: + raise RenderThreadTimeoutError(f"The render thread left {what} unanswered for {self._answer_timeout} s") diff --git a/tests/suite/screens/dearpygui/display.py b/tests/suite/screens/dearpygui/display.py new file mode 100644 index 000000000..d792bf0d8 --- /dev/null +++ b/tests/suite/screens/dearpygui/display.py @@ -0,0 +1,78 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from typing import Final + +from pyvirtualdisplay.display import Display + +KEEP_STATE_BETWEEN_CLIENTS: Final[str] = "-noreset" + + +class DisplayBackend(StrEnum): + """The X server program a display runs on: Xvfb draws into memory, Xephyr into a window on the desktop.""" + + XVFB = "xvfb" + XEPHYR = "xephyr" + + @property + def program(self) -> str: + """The name of the X server program that draws for this backend.""" + return "Xvfb" if self is DisplayBackend.XVFB else "Xephyr" + + +class DisplayServerMissingError(RuntimeError): + """Raised when the X server a run draws on is not installed.""" + + +@dataclass(frozen=True) +class ScreenSize: + """The pixels a display's screen spans.""" + + width: int + height: int + + +class VirtualDisplay: + """An X server of a worker's own, which its scenarios draw on one after another. + + Xvfb draws into memory, which suits a run nobody watches. Xephyr draws into a window on the + desktop, which lets a person follow a scenario as it plays. Either server takes every event a + scenario sends, so the desktop around it stays untouched. + + The server goes on as it stands when its last client leaves. A server that resets then refuses + the connection an application opens a moment after another of its clients closed, and each + reset recompiles the keymap into output nobody reads, which stalls the server once that output + fills its pipe. + """ + + def __init__( + self, + backend: DisplayBackend, + size: ScreenSize, + ) -> None: + self._program = backend.program + self._server = Display( + backend=backend.value, + size=(size.width, size.height), + extra_args=[KEEP_STATE_BETWEEN_CLIENTS], + manage_global_env=False, + ) + + def start(self) -> str: + """Starts the server and returns the name a client connects to it under, such as ``:3``. + + Raises: + DisplayServerMissingError: If the server's program is not installed. + """ + if shutil.which(self._program) is None: + raise DisplayServerMissingError( + f"{self._program} is not installed. Install it from the system's packages, such as " + f"the xvfb package on Debian and Ubuntu, to run the screen scenarios." + ) + + self._server.start() + return self._server.new_display_var + + def stop(self) -> None: + """Stops the server and frees the display name it served.""" + self._server.stop() diff --git a/tests/suite/screens/dearpygui/geometry.py b/tests/suite/screens/dearpygui/geometry.py new file mode 100644 index 000000000..7fa7027f0 --- /dev/null +++ b/tests/suite/screens/dearpygui/geometry.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence + + +@dataclass(frozen=True) +class Point: + """A position on the screen, in whole pixels from its top left corner.""" + + x: int + y: int + + +@dataclass(frozen=True) +class Rect: + """A box on the screen: its top left corner and its size, in pixels.""" + + x: float + y: float + width: float + height: float + + @classmethod + def of( + cls, + corner: Sequence[float], + size: Sequence[float], + ) -> Rect: + """The box standing at ``corner`` with ``size``, as DearPyGui reports both.""" + return cls( + x=corner[0], + y=corner[1], + width=size[0], + height=size[1], + ) + + @property + def center(self) -> Point: + """The pixel a pointer aims at to land on the box.""" + return Point( + x=round(self.x + self.width / 2), + y=round(self.y + self.height / 2), + ) + + def overlap(self, other: Rect) -> Optional[Rect]: + """The part of this box ``other`` covers too, or ``None`` where the two do not meet.""" + left = max(self.x, other.x) + top = max(self.y, other.y) + right = min(self.x + self.width, other.x + other.width) + bottom = min(self.y + self.height, other.y + other.height) + if right <= left or bottom <= top: + return None + + return Rect(x=left, y=top, width=right - left, height=bottom - top) + + def holds(self, point: Point) -> bool: + """Whether ``point`` lies within this box, edges included.""" + return self.x <= point.x <= self.x + self.width and self.y <= point.y <= self.y + self.height + + def contains(self, other: Rect) -> bool: + """Whether ``other`` lies within this box, edges included.""" + return ( + self.x <= other.x + and self.y <= other.y + and other.x + other.width <= self.x + self.width + and other.y + other.height <= self.y + self.height + ) diff --git a/tests/suite/screens/dearpygui/gestures/__init__.py b/tests/suite/screens/dearpygui/gestures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/dearpygui/gestures/arrival.py b/tests/suite/screens/dearpygui/gestures/arrival.py new file mode 100644 index 000000000..ab6da7537 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/arrival.py @@ -0,0 +1,198 @@ +from contextlib import suppress +from functools import partial +from typing import Callable, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge, RenderThreadStoppedError +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.gestures.constants import REACH_TIMEOUT_SECONDS +from tests.suite.screens.dearpygui.gestures.errors import GestureLostError +from tests.suite.screens.dearpygui.gestures.witness import InputWitness +from tests.suite.screens.dearpygui.items.reading import read_hovered +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_pointer, read_viewport +from tests.suite.screens.dearpygui.keys import key_name +from tests.suite.screens.dearpygui.reach import UnreachableError, reachable +from tests.suite.screens.dearpygui.xtest import XTestDevice + +ARRIVAL_FRAMES: Final[int] = 10 +IMGUI_DOUBLE_CLICK_SECONDS: Final[float] = 0.30 + + +class Arrival: + """The part of a hand that lets frames pass and confirms that its input reached DearPyGui. + + It owns the bridge, the display's input device, the witness of what Dear ImGui counted and the time of + the last release. It aims at an item once the item stands in reach and holds still, waits out a + double-click window, confirms that the pointer, a held button or a held key reads where it was put, and + confirms a press that came and went from the witness's counts. `Pointer` and `Keyboard` build their + gestures on these steps, and `Scrolling` reaches them through `Pointer`. + """ + + def __init__( + self, + bridge: Bridge, + device: XTestDevice, + ) -> None: + self._bridge = bridge + self._device = device + self._witness = InputWitness() + self._last_release: Optional[float] = None + + def _settle(self, count: int) -> None: + """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" + with suppress(RenderThreadStoppedError): + self._bridge.frames(count) + + def _application_seconds(self) -> Optional[float]: + """The application's clock, or ``None`` once a gesture has closed the application.""" + try: + return float(self._bridge.ask(dpg.get_total_time)) + except RenderThreadStoppedError: + return None + + def _outlast_a_double_click(self) -> None: + """Waits until the last press lies further back than a double-click reaches, in the application's clock. + + A person's next gesture comes later than that, while frames drawn quickly bring it within + reach, where Dear ImGui counts it with the clicks before it. + """ + last = self._last_release + if last is None: + return + + while float(self._bridge.ask(dpg.get_total_time)) - last < IMGUI_DOUBLE_CLICK_SECONDS: + self._bridge.frames(ONE_FRAME) + + def _on_display(self, point: Point) -> Point: + """The display pixel at ``point`` of the viewport.""" + viewport = self._bridge.ask(read_viewport) + return Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y) + + def _aim(self, item: Item) -> Point: + """The screen pixel at the middle of ``item``, once a user could press it and it holds still. + + A control takes frames to appear after the gesture that brings it, and a window sized by its + content settles its place over its first frames. The hand waits until the control is in reach + and stands where it stood a frame before, the way a person waits for a dialog to stop moving, + and reports why it stayed out of reach once the wait runs out. + """ + + def read() -> Point: + center = reachable(item).center + viewport = read_viewport() + return Point( + x=round(viewport.x) + center.x, + y=round(viewport.y) + center.y, + ) + + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) + previous: Optional[Point] = None + while True: + try: + point = self._bridge.ask(read) + except UnreachableError: + if deadline.passed(): + raise + point = None + + if point is not None and point == previous: + return point + if deadline.passed(): + raise UnreachableError(f"{item!r} kept moving: it stood at {previous} and then at {point}") + + previous = point + self._bridge.frames(ONE_FRAME) + + def _hovered_within(self, item: Item, frames: int) -> bool: + """Whether ``item`` reports the pointer resting on it within ``frames`` frames, or reports no hover at all. + + An item standing in a tree that rebuilds reports no hover until the rebuild lets it go, which + is why the hover is aimed again until the reach time runs out. + """ + for _ in range(frames): + if self._bridge.ask(lambda: read_hovered(item)) is not False: + return True + + self._bridge.frames(ONE_FRAME) + + return False + + def _confirm_pointer(self, point: Point) -> None: + """Waits a few frames for the application to read the pointer at display pixel ``point``. + + Raises: + GestureLostError: If the application reads the pointer elsewhere throughout. + """ + viewport = self._bridge.ask(read_viewport) + expected = Point(x=point.x - round(viewport.x), y=point.y - round(viewport.y)) + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(read_pointer) == expected: + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"The pointer was moved to {expected} and the application reads it elsewhere") + + def _await_count( + self, + count: Callable[[], int], + target: int, + what: str, + ) -> bool: + """Waits a few frames until the witness's ``count`` reaches ``target``, and says whether the application + still runs. + + A display busy with other work hands input on a few frames late, so the count is read once a frame + for a few frames. A gesture that closes the application ends the wait where the application stopped. + + Raises: + GestureLostError: If the count stays short of ``target`` throughout. + """ + counted = 0 + for _ in range(ARRIVAL_FRAMES): + try: + counted = self._bridge.ask(count) + except RenderThreadStoppedError: + return False + if counted >= target: + return True + + self._settle(ONE_FRAME) + + raise GestureLostError( + f"{what} was pressed on the display, and the application counted {counted} of the {target} " + f"releases the press brings it to" + ) + + def _confirm_keys(self, keys: Sequence[int]) -> None: + for key in keys: + self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") + + def _confirm( + self, + is_down: Callable[[], bool], + what: str, + ) -> None: + """Checks that the application reads a held button or key as down, unless the gesture closed it. + + A display busy with other work hands the press on a few frames late, so the check reads it + once a frame for a few frames while it stays held. + + Raises: + GestureLostError: If the application reads it as up throughout. + """ + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(is_down): + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"{what} is held on the display and the application reads it as up") diff --git a/tests/suite/screens/dearpygui/gestures/constants.py b/tests/suite/screens/dearpygui/gestures/constants.py new file mode 100644 index 000000000..c21a8ae79 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +HOVER_FRAMES: Final[int] = 2 +HOLD_FRAMES: Final[int] = 2 +RELEASE_FRAMES: Final[int] = 1 +SETTLE_FRAMES: Final[int] = 2 +REACH_TIMEOUT_SECONDS: Final[float] = 10.0 diff --git a/tests/suite/screens/dearpygui/gestures/errors.py b/tests/suite/screens/dearpygui/gestures/errors.py new file mode 100644 index 000000000..5ad2907d0 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/errors.py @@ -0,0 +1,6 @@ +class GestureLostError(AssertionError): + """Raised when a gesture's input never reached the control it was aimed at.""" + + +class SlowFramesError(GestureLostError): + """Raised when the display draws its frames too slowly for Dear ImGui to read a gesture the way a person meant it.""" diff --git a/tests/suite/screens/dearpygui/gestures/keyboard.py b/tests/suite/screens/dearpygui/gestures/keyboard.py new file mode 100644 index 000000000..b47cd5c65 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/keyboard.py @@ -0,0 +1,58 @@ +from functools import partial +from typing import Sequence + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME +from tests.suite.screens.dearpygui.gestures.arrival import Arrival +from tests.suite.screens.dearpygui.gestures.constants import HOLD_FRAMES, SETTLE_FRAMES +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT, key_name, keysym_of, keysym_of_character + + +class Keyboard(Arrival): + """The keyboard of a hand: key presses with modifiers, and typed text. + + It owns every gesture made with keys and builds each from `Arrival`'s steps. Modifiers go down first and + read as held, and the key itself goes down and comes up in one go, so Dear ImGui reads it down for a + single frame at any frame rate, the one press a person made, and the witness confirms its release. The + full hand also uses it to replace the text of a field. + """ + + def press_key( + self, + key: int, + *, + modifiers: Sequence[int], + ) -> None: + """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + try: + if modifiers: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + + released = partial(self._witness.key_releases, key) + before = self._bridge.ask(released) + self._device.tap_key(keysym_of(key)) + self._await_count(released, before + 1, f"The key {key_name(key)}") + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + + self._settle(SETTLE_FRAMES) + + def type_text(self, text: str) -> None: + """Types ``text`` into whatever holds the keyboard, one character a frame.""" + shift = keysym_of(IMGUI_LEFT_SHIFT) + for character in text: + keysym = keysym_of_character(character) + shifted = self._device.needs_shift(keysym) + if shifted: + self._device.key_down(shift) + try: + self._device.tap_key(keysym) + self._settle(ONE_FRAME) + finally: + if shifted: + self._device.key_up(shift) + + self._settle(SETTLE_FRAMES) diff --git a/tests/suite/screens/dearpygui/gestures/pointer.py b/tests/suite/screens/dearpygui/gestures/pointer.py new file mode 100644 index 000000000..c77cb2502 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/pointer.py @@ -0,0 +1,267 @@ +from typing import Dict, Final, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.gestures.arrival import IMGUI_DOUBLE_CLICK_SECONDS, Arrival +from tests.suite.screens.dearpygui.gestures.constants import ( + HOLD_FRAMES, + HOVER_FRAMES, + REACH_TIMEOUT_SECONDS, + RELEASE_FRAMES, + SETTLE_FRAMES, +) +from tests.suite.screens.dearpygui.gestures.errors import GestureLostError, SlowFramesError +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.keys import keysym_of +from tests.suite.screens.dearpygui.xtest import MouseButton + +SINGLE_PRESS: Final[int] = 1 +DOUBLE_PRESS: Final[int] = 2 +TRIPLE_PRESS: Final[int] = 3 +HOVER_ARRIVAL_FRAMES: Final[int] = 5 +DRAG_STEPS: Final[int] = 8 + +IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { + MouseButton.LEFT: dpg.mvMouseButton_Left, + MouseButton.RIGHT: dpg.mvMouseButton_Right, + MouseButton.MIDDLE: dpg.mvMouseButton_Middle, +} + + +class Pointer(Arrival): + """The pointer of a hand: presses, double-clicks, drags and hovering. + + It owns every gesture made with the buttons and builds each from `Arrival`'s steps: aim at the item, + rest over it, press, confirm the press and settle. A click goes down and comes up in one go, and so do + the presses of a double-click, so Dear ImGui reads each press on a frame of its own at any frame rate. + The presses of a double-click then land two frames apart, and Dear ImGui counts them as one double-click + while those frames take less than its double-click time between them. `Scrolling` reuses its hover and + drag to reach scrollbars and the wheel. + """ + + def click(self, item: Item) -> None: + """Presses ``item`` with the left button.""" + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + + def click_holding( + self, + item: Item, + modifiers: Sequence[int], + ) -> None: + """Presses ``item`` with the left button while holding the Dear ImGui keys in ``modifiers``. + + The keys go down a frame before the button and come up once the press has settled, which is + how a person makes a Ctrl-click, so an application reading them while it answers the press + finds them held. + """ + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + try: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + + self._settle(SETTLE_FRAMES) + + def double_click(self, item: Item) -> None: + """Presses ``item`` twice in quick succession with the left button.""" + self._press(item, MouseButton.LEFT, DOUBLE_PRESS) + + def double_click_held( + self, + item: Item, + *, + frames: int, + ) -> None: + """Presses ``item`` twice with the left button, holding the second press down for ``frames`` frames. + + A person lets go of a click some frames after pressing it, and the double-click is answered + as the second press goes down, so whatever the answer brings under the pointer meets a held + button. + """ + self.hover(item) + self._outlast_a_double_click() + imgui_button = IMGUI_MOUSE_BUTTONS[MouseButton.LEFT] + releases, double_clicks = self._bridge.ask(lambda: self._counts(imgui_button)) + self._device.click_button(MouseButton.LEFT, SINGLE_PRESS) + self._device.button_down(MouseButton.LEFT) + try: + self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), "The left button") + self._require_double_click(imgui_button, releases, double_clicks) + self._settle(frames) + finally: + self._device.button_up(MouseButton.LEFT) + self._await_count( + lambda: self._witness.button_releases(imgui_button), + releases + DOUBLE_PRESS, + "The left button", + ) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def right_click(self, item: Item) -> None: + """Presses ``item`` with the right button, the gesture that opens a context menu.""" + self._press(item, MouseButton.RIGHT, SINGLE_PRESS) + + def click_at(self, point: Point) -> None: + """Presses the left button at ``point`` of the viewport, for a target DearPyGui reports a position alone for. + + The press confirms it reached the application, while what it landed on is for the scenario + to read from what the press changed. + """ + self._press_at(point, MouseButton.LEFT, SINGLE_PRESS) + + def double_click_at(self, point: Point) -> None: + """Presses the left button twice in quick succession at ``point`` of the viewport.""" + self._press_at(point, MouseButton.LEFT, DOUBLE_PRESS) + + def right_click_at(self, point: Point) -> None: + """Presses the right button at ``point`` of the viewport, for a menu a place offers rather than an item.""" + self._press_at(point, MouseButton.RIGHT, SINGLE_PRESS) + + def triple_click_at(self, point: Point) -> None: + """Presses the left button three times in quick succession at ``point`` of the viewport.""" + self._press_at(point, MouseButton.LEFT, TRIPLE_PRESS) + + def move_to(self, point: Point) -> None: + """Rests the pointer at ``point`` of the viewport.""" + self._device.move(self._on_display(point)) + self._settle(HOVER_FRAMES) + + def drag(self, start: Point, end: Point) -> None: + """Presses the left button at ``start`` of the viewport, carries it to ``end`` over a few frames, and lets go. + + The press is confirmed held as the pointer sets off, so a drag the application never saw + fails where it was lost. + """ + self._outlast_a_double_click() + self._drag_between(self._on_display(start), self._on_display(end)) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def hover(self, item: Item) -> None: + """Rests the pointer over ``item`` until the item reports it. + + Raises: + GestureLostError: If the item never reports the pointer resting on it. + """ + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) + while True: + self._device.move(self._aim(item)) + self._bridge.frames(HOVER_FRAMES) + if self._hovered_within(item, HOVER_ARRIVAL_FRAMES): + return + if deadline.passed(): + raise GestureLostError( + f"The pointer was aimed at {item!r} and the item reports no hover: " + f"something covers it, or the region it scrolls in clips it" + ) + + def _press( + self, + item: Item, + button: MouseButton, + count: int, + ) -> None: + self.hover(item) + self._outlast_a_double_click() + self._press_where_it_stands(button, count) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _press_where_it_stands( + self, + button: MouseButton, + count: int, + ) -> None: + """Presses ``button`` ``count`` times in one go where the pointer stands, and confirms Dear ImGui read + every release, and the double-click when the presses are more than one. + """ + imgui_button = IMGUI_MOUSE_BUTTONS[button] + releases, double_clicks = self._bridge.ask(lambda: self._counts(imgui_button)) + self._device.click_button(button, count) + running = self._await_count( + lambda: self._witness.button_releases(imgui_button), + releases + count, + f"The {button.name.lower()} button", + ) + if running and count > SINGLE_PRESS: + self._require_double_click(imgui_button, releases, double_clicks) + + def _counts(self, imgui_button: int) -> Tuple[int, int]: + """The releases and the double-clicks the witness counted of ``imgui_button`` so far.""" + return ( + self._witness.button_releases(imgui_button), + self._witness.double_clicks(imgui_button), + ) + + def _require_double_click( + self, + imgui_button: int, + releases: int, + double_clicks: int, + ) -> None: + """Checks that Dear ImGui counted a double-click since the witness counted ``double_clicks`` of them. + + The presses came two frames apart, so a missing double-click says the display drew those frames + more slowly than Dear ImGui's double-click time allows. + + Raises: + SlowFramesError: If Dear ImGui counted the presses as separate clicks. + """ + if self._bridge.ask(lambda: self._witness.double_clicks(imgui_button)) > double_clicks: + return + + times = self._bridge.ask(lambda: self._witness.button_release_times(imgui_button))[releases:] + took = f"{times[1] - times[0]:.2f} s" if len(times) > 1 else "longer than that" + raise SlowFramesError( + f"Dear ImGui counted no double-click: it counts two presses landing within " + f"{IMGUI_DOUBLE_CLICK_SECONDS} s of each other, and the two frames between these took {took}" + ) + + def _press_at( + self, + point: Point, + button: MouseButton, + count: int, + ) -> None: + self.move_to(point) + self._outlast_a_double_click() + self._press_where_it_stands(button, count) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _drag_between(self, start: Point, end: Point) -> None: + """Presses the left button at display pixel ``start``, carries it to ``end`` over a few frames, and lets go. + + Each step waits until the application reads the pointer where the step put it, so every + point the drag passes through reaches the application while the button is down, and the + pointer rests at ``end`` a few frames before the button comes up, as a person stops before + letting go. + """ + self._device.move(start) + self._settle(HOVER_FRAMES) + self._device.button_down(MouseButton.LEFT) + try: + self._settle(HOLD_FRAMES) + self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") + + for step in range(1, DRAG_STEPS + 1): + point = Point( + x=start.x + round((end.x - start.x) * step / DRAG_STEPS), + y=start.y + round((end.y - start.y) * step / DRAG_STEPS), + ) + self._device.move(point) + self._settle(ONE_FRAME) + self._confirm_pointer(point) + + self._settle(HOLD_FRAMES) + finally: + self._device.button_up(MouseButton.LEFT) + + self._settle(RELEASE_FRAMES) diff --git a/tests/suite/screens/dearpygui/gestures/scrolling.py b/tests/suite/screens/dearpygui/gestures/scrolling.py new file mode 100644 index 000000000..0d5875dbc --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/scrolling.py @@ -0,0 +1,213 @@ +from functools import partial +from typing import Final + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.gestures.constants import HOVER_FRAMES, SETTLE_FRAMES +from tests.suite.screens.dearpygui.gestures.pointer import Pointer +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import ( + enclosing_regions, + read_region_view, + read_scroll, + read_visible_box, +) +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_viewport +from tests.suite.screens.dearpygui.reach import UnreachableError +from tests.suite.screens.dearpygui.xtest import MouseButton + +WHEEL_FRAMES: Final[int] = 1 +SCROLL_NOTCH_LIMIT: Final[int] = 100 +EDGE_INSET: Final[int] = 3 +GRIP_MARGIN_PIXELS: Final[float] = 8.0 +GRIP_DRAGS: Final[int] = 4 +IMGUI_GRAB_MIN_SIZE: Final[float] = 12.0 + + +class Scrolling(Pointer): + """The wheel and the scrollbars of a hand: turning the wheel over a region and bringing an item into view. + + It owns every gesture that moves a region's scroll. It rests the pointer with `Pointer`'s hover, + turns the wheel through the display's input device, and drags a scrollbar grip with `Pointer`'s drag + where the wheel leaves the region alone. + """ + + def wheel( + self, + item: Item, + notches: int, + ) -> None: + """Rests the pointer over ``item`` and turns the wheel ``notches`` notches, down for a positive count.""" + self.hover(item) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def wheel_at( + self, + point: Point, + notches: int, + ) -> None: + """Rests the pointer at viewport point ``point`` and turns the wheel ``notches`` notches, down if positive.""" + self.move_to(point) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def turn_wheel_over( + self, + region: Item, + notches: int, + ) -> None: + """Turns the wheel ``notches`` notches over the right edge of ``region``, down for a positive count. + + The pointer goes where it goes whatever stands open, so the gesture reports nothing; the + scenario reads what the turn moved. + """ + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def scroll_to_end(self, region: Item) -> None: + """Drags the grip of ``region``'s scrollbar to the bottom, as a person reaches the end of a long list. + + A list drawing only the rows in view settles its length as it scrolls, so the grip is dragged + again while the end moves on, and a turn of the wheel takes up the pixels the grip's whole + steps leave over. + + Raises: + UnreachableError: If the region stands short of its end afterwards. + """ + self.scroll_into_view(region) + for _ in range(GRIP_DRAGS): + before = self._bridge.ask(lambda: read_scroll(region)) + if before.position >= before.maximum: + return + + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.maximum + GRIP_MARGIN_PIXELS)) + self._drag_between(start, end) + self._settle(SETTLE_FRAMES) + + short = self._bridge.ask(lambda: read_scroll(region)) + if short.position < short.maximum and short.by_wheel: + self.turn_wheel_over(region, 1) + + after = self._bridge.ask(lambda: read_scroll(region)) + if after.position < after.maximum: + raise UnreachableError(f"The region {region!r} stands short of its end at {after}") + + def scroll_into_view(self, item: Item) -> None: + """Turns the wheel over the regions ``item`` scrolls in until the item stands whole in view. + + The nearest region that scrolls is moved first, at its right edge, and the regions around it + after. The wheel moves a region that takes it: the first notch shows how far one notch carries + the region, and each later turn takes as many notches as the distance left needs, the way a + person spins the wheel toward a row. A region the wheel leaves alone is moved by dragging its + scrollbar's grip to where the grip stands for the scroll the item needs, and a few pixels on, + since the grip moves in whole pixels and each of them scrolls further than one. + + Raises: + UnreachableError: If a region stops moving, or the wheel runs out of turns, first. + """ + for region in self._bridge.ask(lambda: enclosing_regions(item)): + self._scroll_within(item, region) + + self._settle(SETTLE_FRAMES) + + def _scroll_within(self, item: Item, region: Item) -> None: + notch = 0.0 + for _ in range(SCROLL_NOTCH_LIMIT): + distance = self._bridge.ask(lambda: _distance_from_view(item, region)) + before = self._bridge.ask(lambda: read_scroll(region)) + if distance == 0 or before.maximum == 0: + return + + if before.by_wheel: + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 + self._turn_wheel(notches if distance > 0 else -notches) + else: + margin = GRIP_MARGIN_PIXELS if distance > 0 else -GRIP_MARGIN_PIXELS + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.position + distance + margin)) + self._drag_between(start, end) + notches = 1 + + self._settle(ONE_FRAME) + after = self._bridge.ask(lambda: read_scroll(region)) + if after == before: + raise UnreachableError(f"{item!r} stays out of view: the region {region!r} stopped at {before}") + + notch = abs(after.position - before.position) / notches + + raise UnreachableError(f"{item!r} stays out of view after {SCROLL_NOTCH_LIMIT} turns of the wheel") + + def _turn_wheel(self, notches: int) -> None: + button = MouseButton.WHEEL_DOWN if notches > 0 else MouseButton.WHEEL_UP + for _ in range(abs(notches)): + self._device.button_down(button) + self._settle(WHEEL_FRAMES) + self._device.button_up(button) + self._settle(WHEEL_FRAMES) + + +def _distance_from_view(item: Item, region: Item) -> float: + """How far the region must scroll for ``item`` to stand whole in its view: down for a positive distance. + + Runs on the render thread. + """ + item_box = read_item(item).rect + region_box = read_region_view(region) + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + below = item_box.y + item_box.height - (region_box.y + region_box.height) + if below > 0: + return below + above = item_box.y - region_box.y + if above < 0: + return above + + return 0.0 + + +def _wheel_point(region: Item) -> Point: + """A pixel at the right edge of the part of ``region`` left in view, where its scrollbar stands. + + The middle of a region often lies over a smaller region it holds, which would take the wheel; + the edge belongs to the region itself. Runs on the render thread. + """ + visible = read_visible_box(region) + if visible is None: + raise UnreachableError(f"No part of the region {region!r} stands in view to turn the wheel over") + + viewport = read_viewport() + return Point( + x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, + y=round(viewport.y) + visible.center.y, + ) + + +def _grip_point(region: Item, scroll: float) -> Point: + """The pixel at the middle of ``region``'s scrollbar grip while the region stands scrolled to ``scroll``. + + The grip spans the share of the content the region shows, held at Dear ImGui's smallest grip, + and travels the rest of the track as the region scrolls from its top to its end. The track spans + the region's whole height, so the region stands whole in view for its grip to be dragged. Runs on + the render thread. + """ + box = read_item(region).rect + if box is None: + raise UnreachableError(f"The region {region!r} reports no box to drag its scrollbar in") + + reading = read_scroll(region) + target = min(max(scroll, 0.0), reading.maximum) + grip = min(max(box.height * box.height / (box.height + reading.maximum), IMGUI_GRAB_MIN_SIZE), box.height) + travel = box.height - grip + viewport = read_viewport() + return Point( + x=round(viewport.x + box.x + box.width) - EDGE_INSET, + y=round(viewport.y + box.y + grip / 2 + travel * target / reading.maximum), + ) diff --git a/tests/suite/screens/dearpygui/gestures/witness.py b/tests/suite/screens/dearpygui/gestures/witness.py new file mode 100644 index 000000000..f7b48f620 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/witness.py @@ -0,0 +1,62 @@ +from collections import Counter +from typing import Dict, Final, List, Optional, Tuple, Union + +import dearpygui.dearpygui as dpg + +ANY_KEY: Final[int] = 0 +ANY_BUTTON: Final[int] = -1 + + +class InputWitness: + """What Dear ImGui counted of the display's input: each release of a key or a button, and each double-click. + + It is a global handler registry of its own in the application's DearPyGui context, so its counts grow as + the application's frames read the input, and a count stays once a press has come and gone. A hand reads + them to confirm a press that stood down for a single frame. Every call runs on the render thread, and the + first call puts the registry in place. + """ + + def __init__(self) -> None: + self._registry: Optional[Union[int, str]] = None + self._key_releases: Counter[int] = Counter() + self._button_release_times: Dict[int, List[float]] = {} + self._double_clicks: Counter[int] = Counter() + + def key_releases(self, key: int) -> int: + """How many times Dear ImGui read the Dear ImGui key ``key`` coming up.""" + self._install() + return self._key_releases[key] + + def button_releases(self, button: int) -> int: + """How many times Dear ImGui read the Dear ImGui mouse button ``button`` coming up.""" + self._install() + return len(self._button_release_times.get(button, ())) + + def button_release_times(self, button: int) -> Tuple[float, ...]: + """The application's clock at each release of the Dear ImGui mouse button ``button``, oldest first.""" + self._install() + return tuple(self._button_release_times.get(button, ())) + + def double_clicks(self, button: int) -> int: + """How many double-clicks Dear ImGui counted with the Dear ImGui mouse button ``button``.""" + self._install() + return self._double_clicks[button] + + def _install(self) -> None: + if self._registry is not None and dpg.does_item_exist(self._registry): + return + + with dpg.handler_registry() as registry: + dpg.add_key_release_handler(key=ANY_KEY, callback=self._on_key_released) + dpg.add_mouse_release_handler(button=ANY_BUTTON, callback=self._on_button_released) + dpg.add_mouse_double_click_handler(button=ANY_BUTTON, callback=self._on_double_clicked) + self._registry = registry + + def _on_key_released(self, _sender: Union[int, str], key: int) -> None: + self._key_releases[key] += 1 + + def _on_button_released(self, _sender: Union[int, str], button: int) -> None: + self._button_release_times.setdefault(button, []).append(float(dpg.get_total_time())) + + def _on_double_clicked(self, _sender: Union[int, str], button: int) -> None: + self._double_clicks[button] += 1 diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py new file mode 100644 index 000000000..c5bd55dc1 --- /dev/null +++ b/tests/suite/screens/dearpygui/hand.py @@ -0,0 +1,38 @@ +from tests.suite.screens.dearpygui.gestures.keyboard import Keyboard +from tests.suite.screens.dearpygui.gestures.scrolling import Scrolling +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.keys import IMGUI_BACKSPACE, IMGUI_LEFT_CTRL, IMGUI_LETTER_A + + +class Hand(Scrolling, Keyboard): + """A user's hand on the application: the pointer and the keyboard of the scenario's display. + + A gesture first reads where its control stands, refusing one beyond a user's reach, and then + plays the events a person makes. Every step waits whole frames, since the application reads + input once a frame: the pointer rests over a control before pressing it, and a modifier is held + a frame before the key it modifies goes down. A click and a key press go down and come up in one + go, which Dear ImGui reads over two frames, so a press means what a person meant at any frame + rate: a key stands down too briefly to repeat, and the presses of a double-click land two frames + apart. A gesture that closes the application ends where the application stopped, which is how a + scenario presses the button that leaves it. + + A gesture confirms that it arrived. The control reports the pointer resting on it before a + button goes down, a held button or modifier reads as down while it is held, and Dear ImGui + counts the release of every click and key press and the double-click of a double press. A press + lost on the way fails where it was lost, and a scenario expecting nothing to happen learns that + its gesture was made. + + The pieces are `Arrival` (waiting and confirming), `Pointer` (presses and drags), `Scrolling` + (the wheel and the scrollbars) and `Keyboard` (keys and typed text). + """ + + def replace_text( + self, + item: Item, + text: str, + ) -> None: + """Clicks the field ``item``, selects what it holds, deletes it, and types ``text`` in its place.""" + self.click(item) + self.press_key(IMGUI_LETTER_A, modifiers=[IMGUI_LEFT_CTRL]) + self.press_key(IMGUI_BACKSPACE, modifiers=[]) + self.type_text(text) diff --git a/tests/suite/screens/dearpygui/hosting.py b/tests/suite/screens/dearpygui/hosting.py new file mode 100644 index 000000000..200ea39c6 --- /dev/null +++ b/tests/suite/screens/dearpygui/hosting.py @@ -0,0 +1,89 @@ +import threading +from typing import Callable, Final, List, Protocol + +SCENARIO_THREAD_NAME: Final[str] = "scenario" + + +class ScenarioHangError(AssertionError): + """Raised when a scenario's thread is still running after the application stopped.""" + + +class HostedLoop(Protocol): + """An application's render loop, run on the main thread while a scenario drives it from another. + + The host runs the loop where a windowing system wants its events handled. The scenario finishes + the run from its own thread, and the host closes it once the loop has returned. + """ + + def run(self) -> None: + """Draws frames on this thread until the application stops.""" + + def finish(self, *, failed: bool) -> None: + """Brings the application to a stop once the scenario is done. Runs on the scenario's thread. + + Raises: + AssertionError: Naming what the application showed wrong while it stopped. + """ + + def close(self) -> None: + """Reads what the stopped application left behind. Runs on the main thread after the loop. + + Raises: + AssertionError: Naming what the application left wrong. + """ + + +def host( + scenario: Callable[[], None], + loop: HostedLoop, + *, + join_timeout: float, +) -> None: + """Runs ``scenario`` on a thread of its own while this thread runs ``loop``, then reports how it went. + + The first failure is the one raised, and every later one rides along as a note on it, so a + failing scenario still reports what its application did wrong on the way out. + + Raises: + BaseException: The first failure met: the scenario's own, the finish's or the close's. + ScenarioHangError: If the scenario's thread outlives the loop by more than ``join_timeout``. + """ + failures: List[BaseException] = [] + + def drive() -> None: + try: + scenario() + except BaseException as error: # pylint: disable=broad-exception-caught + failures.append(error) + + try: + loop.finish(failed=bool(failures)) + except BaseException as error: # pylint: disable=broad-exception-caught + failures.append(error) + + thread = threading.Thread(target=drive, name=SCENARIO_THREAD_NAME, daemon=True) + thread.start() + loop.run() + thread.join(join_timeout) + if thread.is_alive(): + failures.append( + ScenarioHangError(f"The scenario was still running {join_timeout} s after the application stopped") + ) + + try: + loop.close() + except AssertionError as error: + failures.append(error) + + _raise_first(failures) + + +def _raise_first(failures: List[BaseException]) -> None: + if not failures: + return + + first, *rest = failures + for later in rest: + first.add_note(f"Also: {type(later).__name__}: {later}") + + raise first diff --git a/tests/suite/screens/dearpygui/isolation.py b/tests/suite/screens/dearpygui/isolation.py new file mode 100644 index 000000000..04b4fafc8 --- /dev/null +++ b/tests/suite/screens/dearpygui/isolation.py @@ -0,0 +1,229 @@ +import json +import signal +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Literal, Mapping, Optional, Sequence, Union + +import pytest + +PHASES: Final[Sequence[Literal["setup", "call", "teardown"]]] = ("setup", "call", "teardown") + +CHILD_ARGUMENTS: Final[Sequence[str]] = ( + "-p", + "no:cacheprovider", + "-p", + "no:cov", + "-p", + "no:xdist", + "-q", + "--no-header", +) + +OUTPUT_TAIL_CHARACTERS: Final[int] = 20_000 +NODE_SEPARATOR: Final[str] = "::" +XFAIL_MARKER: Final[str] = "xfail" +XFAIL_REASON: Final[str] = "reason" + + +@dataclass(frozen=True) +class ChildRun: + """How a child process ended: its exit status, or ``None`` when it ran out of time, and its output.""" + + returncode: Optional[int] + output: str + + @property + def crashed(self) -> bool: + """Whether the process ended by a signal or ran out of time.""" + return self.returncode is None or self.returncode < 0 + + @property + def killed_by_signal(self) -> bool: + """Whether the process ended by a signal, such as a crash.""" + return self.returncode is not None and self.returncode < 0 + + @property + def ending(self) -> str: + """The way the process ended, in words a failure report can carry.""" + if self.returncode is None: + return "ran out of time and was stopped" + if self.returncode < 0: + return f"was killed by {signal.Signals(-self.returncode).name}" + + return f"exited with status {self.returncode}" + + +class ReportRecorder: + """Writes each report the child's run makes to a file, in the form pytest sends between processes. + + A report is written the moment its phase ends, so a process that dies in a later phase still + leaves the earlier reports behind. + """ + + def __init__( + self, + config: pytest.Config, + path: Path, + ) -> None: + self._config = config + self._path = path + + @pytest.hookimpl + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + """Appends the report of one finished phase to the file as a line of JSON.""" + data = self._config.hook.pytest_report_to_serializable(config=self._config, report=report) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(data) + "\n") + + +def run_isolated( + item: pytest.Item, + *, + environment: Mapping[str, str], + working_directory: Path, + report_path: Path, + timeout: float, +) -> List[pytest.TestReport]: + """Runs ``item`` in a fresh interpreter and returns its reports, as if it had run here. + + The child is a pytest run of the item's node id alone, under ``environment`` and started in + ``working_directory``, which is where the application under test finds itself launched. It + writes its reports to ``report_path``. A phase missing from them, because the child crashed or + ran out of time, comes back as a failure carrying the child's output, and so does the last phase + of a child that crashed after reporting all of them. + """ + child = _run_child( + item, + environment=environment, + working_directory=working_directory, + timeout=timeout, + ) + reports = _read_reports(item.config, report_path) + return _completed(item, reports, child) + + +def _run_child( + item: pytest.Item, + *, + environment: Mapping[str, str], + working_directory: Path, + timeout: float, +) -> ChildRun: + command = [ + sys.executable, + "-m", + "pytest", + _located_nodeid(item), + f"--rootdir={item.config.rootpath}", + *CHILD_ARGUMENTS, + ] + try: + completed = subprocess.run( + command, + cwd=working_directory, + env=dict(environment), + timeout=timeout, + capture_output=True, + text=True, + check=False, + ) + except subprocess.TimeoutExpired as expired: + return ChildRun( + returncode=None, + output=_text(expired.stdout) + _text(expired.stderr), + ) + + return ChildRun( + returncode=completed.returncode, + output=completed.stdout + completed.stderr, + ) + + +def _located_nodeid(item: pytest.Item) -> str: + """The item's node id with its file named by an absolute path, which a run started anywhere finds.""" + _, separator, name = item.nodeid.partition(NODE_SEPARATOR) + return f"{item.path}{separator}{name}" + + +def _read_reports(config: pytest.Config, path: Path) -> Dict[str, pytest.TestReport]: + if not path.exists(): + return {} + + reports: Dict[str, pytest.TestReport] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + report = config.hook.pytest_report_from_serializable(config=config, data=json.loads(line)) + reports[report.when] = report + + return reports + + +def _completed( + item: pytest.Item, + reports: Dict[str, pytest.TestReport], + child: ChildRun, +) -> List[pytest.TestReport]: + completed: List[pytest.TestReport] = [] + for phase in PHASES: + report = reports.get(phase) + if report is None: + report = _missing_report(item, phase, child, failed=not completed or completed[-1].passed) + + completed.append(report) + if report.failed and phase == "setup": + break + + if child.crashed and len(reports) == len(PHASES): + completed[-1] = _missing_report(item, PHASES[-1], child, failed=True) + + return completed + + +def _missing_report( + item: pytest.Item, + phase: Literal["setup", "call", "teardown"], + child: ChildRun, + *, + failed: bool, +) -> pytest.TestReport: + """The report of a phase the child never wrote, since the process ended first. + + A scenario marked to fail by a known defect, whose process a signal killed, reports that phase as the expected + failure it names: a crash is a defect the ledger records like any other. + """ + expected = item.get_closest_marker(XFAIL_MARKER) + if failed and child.killed_by_signal and expected is not None: + return pytest.TestReport( + nodeid=item.nodeid, + location=item.location, + keywords={keyword: 1 for keyword in item.keywords}, + outcome="skipped", + longrepr=None, + when=phase, + wasxfail=f"{expected.kwargs.get(XFAIL_REASON, '')} (the process {child.ending})", + ) + + longrepr = None + if failed: + longrepr = ( + f"The scenario's process {child.ending} around its {phase}.\n\n{child.output[-OUTPUT_TAIL_CHARACTERS:]}" + ) + + return pytest.TestReport( + nodeid=item.nodeid, + location=item.location, + keywords={keyword: 1 for keyword in item.keywords}, + outcome="failed" if failed else "passed", + longrepr=longrepr, + when=phase, + ) + + +def _text(output: Optional[Union[bytes, str]]) -> str: + if output is None: + return "" + if isinstance(output, bytes): + return output.decode(errors="replace") + + return output diff --git a/tests/suite/screens/dearpygui/items/__init__.py b/tests/suite/screens/dearpygui/items/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/dearpygui/items/colors.py b/tests/suite/screens/dearpygui/items/colors.py new file mode 100644 index 000000000..4f158d9ad --- /dev/null +++ b/tests/suite/screens/dearpygui/items/colors.py @@ -0,0 +1,95 @@ +from typing import Dict, Final, List, Optional, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.items.types import Item + +THEME_COLOR_TYPE: Final[str] = "mvAppItemType::mvThemeColor" +COLOR_ATTRIBUTES: Final[Tuple[str, ...]] = ("color", "fill") +THEME_COLOR_VALUE: Final[str] = "value" +THEME_TARGET: Final[str] = "target" +THEME_CATEGORY: Final[str] = "category" +COLOR_PARTS: Final[int] = 4 +COLOR_DIGITS: Final[int] = 4 +COLOR_STEPS: Final[float] = 255.0 + + +def read_theme(item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, if one is. Runs on the render thread.""" + theme = dpg.get_item_info(item)["theme"] + if theme is None: + return None + + return str(dpg.get_item_alias(theme)) + + +def read_theme_colors(theme: Item) -> Tuple[Tuple[float, ...], ...]: + """The colors every theme color under ``theme`` holds, in the order they were added. Runs on the render thread.""" + colors: List[Tuple[float, ...]] = [] + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + colors.append(tuple(float(part) for part in dpg.get_value(color))) + + return tuple(colors) + + +def read_held_colors() -> Dict[str, Tuple[float, ...]]: + """Every color DearPyGui holds for the interface, keyed by the item and the attribute holding it. Runs on the + render thread. + + A theme color holds its value whether or not anything wearing it is drawn; an item holds its own color and fill + while it stands shown. Every value reads as fractions of the full step, rounded, so a color given in whole steps + reads the same from either side. + """ + held: Dict[str, Tuple[float, ...]] = {} + for item in dpg.get_all_items(): + if dpg.get_item_info(item)["type"] == THEME_COLOR_TYPE: + held[f"{item}:{THEME_COLOR_VALUE}"] = _theme_color(item) + continue + + if not dpg.is_item_shown(item): + continue + + configuration = dpg.get_item_configuration(item) + for attribute in COLOR_ATTRIBUTES: + value = configuration.get(attribute) + if _is_color(value): + held[f"{item}:{attribute}"] = rounded_color(value) + + return held + + +def read_theme_text_color(theme: Item) -> Optional[Tuple[float, ...]]: + """The text color ``theme`` sets, if it sets one. Runs on the render thread.""" + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + configuration = dpg.get_item_configuration(color) + if ( + configuration.get(THEME_TARGET) == dpg.mvThemeCol_Text + and configuration.get(THEME_CATEGORY) == dpg.mvThemeCat_Core + ): + return _theme_color(color) + + return None + + +def rounded_color(value: Sequence[float]) -> Tuple[float, ...]: + """A color's parts, rounded so a color read from DearPyGui and one stated in whole steps compare equal.""" + return tuple(round(float(part), COLOR_DIGITS) for part in value) + + +def _theme_color(item: Item) -> Tuple[float, ...]: + """The color a theme color item holds, as a fraction of the full step like an item's own color reads. + + DearPyGui answers a theme color in whole steps and an item's color in fractions of one. + """ + return rounded_color(tuple(float(part) / COLOR_STEPS for part in dpg.get_value(item))) + + +def _is_color(value: object) -> bool: + """Whether a configuration value is a color DearPyGui holds, which an unset one, read as negatives, is not.""" + return ( + isinstance(value, (list, tuple)) + and len(value) == COLOR_PARTS + and all(isinstance(part, (int, float)) and part >= 0 for part in value) + ) diff --git a/tests/suite/screens/dearpygui/items/reading.py b/tests/suite/screens/dearpygui/items/reading.py new file mode 100644 index 000000000..cd4b6223d --- /dev/null +++ b/tests/suite/screens/dearpygui/items/reading.py @@ -0,0 +1,178 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.types import CHILD_WINDOW_TYPE, WINDOW_TYPE, Item + +TAG_SEPARATOR: Final[str] = "." + + +@dataclass(frozen=True) +class ItemReading: + """What one item showed in the last frame: whether it stands, where, and whether it answers. + + Attributes: + item: The item read. + exists: Whether the item is in the context at all. + shown: Whether the item and every container around it are shown. + visible: Whether the item was drawn inside its clip region in the last frame. + enabled: Whether the item and every container around it answer a press. + rect: The item's box on the screen, where its kind reports one. + """ + + item: Item + exists: bool + shown: bool + visible: bool + enabled: bool + rect: Optional[Rect] + + +def read_item(item: Item) -> ItemReading: + """Reads ``item`` as the last frame drew it. Runs on the render thread.""" + if not dpg.does_item_exist(item): + return ItemReading( + item=item, + exists=False, + shown=False, + visible=False, + enabled=False, + rect=None, + ) + + state = dpg.get_item_state(item) + configuration = dpg.get_item_configuration(item) + return ItemReading( + item=item, + exists=True, + shown=_shown_with_ancestors(item), + visible=bool(state.get("visible", True)), + enabled=bool(configuration.get("enabled", True)) and _enabled_ancestors(item), + rect=_rect(item, state), + ) + + +def find_item( + container: Item, + matches: Callable[[Item], bool], +) -> Optional[Item]: + """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. + + Runs on the render thread. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if matches(item): + return item + + pending[:0] = [child for children in dpg.get_item_info(item)["children"].values() for child in children] + + return None + + +def read_item_count() -> int: + """How many items the context holds, which grows with what the interface builds. Runs on the render thread.""" + return len(dpg.get_all_items()) + + +def read_hovered(item: Item) -> Optional[bool]: + """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. + + Runs on the render thread. An item covered by a popup, clipped by the region it scrolls in, or + passed over while another item holds the mouse reads as not hovered. + """ + state = dpg.get_item_state(item) + if "hovered" not in state: + return None + + return bool(state["hovered"]) + + +def read_value(item: Item) -> Any: + """The value ``item`` holds, whatever kind the item keeps. Runs on the render thread.""" + return dpg.get_value(item) + + +def read_selected_tab(tab_bar: str) -> str: + """The tag of the tab standing in front of ``tab_bar``. Runs on the render thread.""" + return str(dpg.get_item_alias(dpg.get_value(tab_bar))) + + +def is_tag_within(alias: str, prefix: str) -> bool: + """Whether ``alias`` is ``prefix`` itself or a tag composed under it.""" + return alias == prefix or alias.startswith(prefix + TAG_SEPARATOR) + + +def _shown_with_ancestors(tag: Item) -> bool: + item: Optional[Item] = tag + while item is not None: + if not dpg.is_item_shown(item): + return False + + item = dpg.get_item_parent(item) + + return True + + +def _enabled_ancestors(tag: Item) -> bool: + parent = dpg.get_item_parent(tag) + while parent is not None: + if not dpg.get_item_configuration(parent).get("enabled", True): + return False + + parent = dpg.get_item_parent(parent) + + return True + + +def _rect(item: Item, state: Dict[str, Any]) -> Optional[Rect]: + if "rect_min" in state: + return Rect.of(state["rect_min"], state["rect_size"]) + + item_type = dpg.get_item_info(item)["type"] + if item_type == WINDOW_TYPE: + return _window_rect(item) + if item_type == CHILD_WINDOW_TYPE: + return Rect.of(_child_window_corner(item), state["rect_size"]) + + return None + + +def _child_window_corner(child: Item) -> Tuple[float, float]: + """Where a child window stands on the screen. + + DearPyGui reports a child window's position in the content of the window holding it, which + scrolls, so the corner is that window's own corner moved by the position and back by the scroll. + """ + container = _containing_window(child) + if dpg.get_item_info(container)["type"] == WINDOW_TYPE: + corner = dpg.get_item_pos(container) + else: + corner = _child_window_corner(container) + + position = dpg.get_item_state(child)["pos"] + return ( + corner[0] + position[0] - dpg.get_x_scroll(container), + corner[1] + position[1] - dpg.get_y_scroll(container), + ) + + +def _containing_window(item: Item) -> Item: + parent: Optional[Item] = dpg.get_item_parent(item) + while parent is not None and dpg.get_item_info(parent)["type"] not in (WINDOW_TYPE, CHILD_WINDOW_TYPE): + parent = dpg.get_item_parent(parent) + + if parent is None: + raise LookupError(f"{item!r} stands in no window") + + return parent + + +def _window_rect(window: Item) -> Rect: + return Rect.of( + dpg.get_item_pos(window), + dpg.get_item_state(window)["rect_size"], + ) diff --git a/tests/suite/screens/dearpygui/items/regions.py b/tests/suite/screens/dearpygui/items/regions.py new file mode 100644 index 000000000..fe62c0c39 --- /dev/null +++ b/tests/suite/screens/dearpygui/items/regions.py @@ -0,0 +1,133 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import _window_rect, read_item +from tests.suite.screens.dearpygui.items.types import CHILD_WINDOW_TYPE, WINDOW_TYPE, Item +from tests.suite.screens.dearpygui.items.viewport import read_client_area + + +@dataclass(frozen=True) +class ScrollReading: + """How far a region that scrolls stands from its top, how far it can go, in pixels, and what moves it. + + Attributes: + position: How far the region is scrolled down. + maximum: How far the region can scroll down. + by_wheel: Whether the wheel scrolls the region, or its scrollbar alone does. + """ + + position: float + maximum: float + by_wheel: bool + + +@dataclass(frozen=True) +class WindowReading: + """One top-level window as the last frame drew it. + + Attributes: + alias: The window's tag. + label: The title the window carries. + modal: Whether the window holds the screen while it stands. + shown: Whether the window is shown. + rect: The window's box on the screen. + """ + + alias: str + label: str + modal: bool + shown: bool + rect: Rect + + +def read_windows() -> Tuple[WindowReading, ...]: + """Reads every top-level window the context holds. Runs on the render thread.""" + windows = [] + for window in dpg.get_windows(): + if dpg.get_item_info(window)["type"] != WINDOW_TYPE: + continue + + configuration = dpg.get_item_configuration(window) + windows.append( + WindowReading( + alias=str(dpg.get_item_alias(window)), + label=str(configuration.get("label") or ""), + modal=bool(configuration.get("modal")), + shown=bool(dpg.is_item_shown(window)), + rect=_window_rect(window), + ) + ) + + return tuple(windows) + + +def read_scroll(region: Item) -> ScrollReading: + """How far ``region`` is scrolled down. Runs on the render thread.""" + return ScrollReading( + position=dpg.get_y_scroll(region), + maximum=dpg.get_y_scroll_max(region), + by_wheel=not dpg.get_item_configuration(region).get("no_scroll_with_mouse", False), + ) + + +def enclosing_regions(item: Item) -> Tuple[Item, ...]: + """The regions around ``item`` that clip what they hold, the nearest first. Runs on the render thread.""" + regions: List[Item] = [] + parent = dpg.get_item_parent(item) + while parent is not None: + if dpg.get_item_info(parent)["type"] == CHILD_WINDOW_TYPE: + regions.append(parent) + + parent = dpg.get_item_parent(parent) + + return tuple(regions) + + +def read_region_view(region: Item) -> Optional[Rect]: + """The part of ``region``'s box its content shows through, its scrollbars and far padding left out. + + Runs on the render thread. A region reports the room left for its content, which is its box + less its padding on both sides and the scrollbars standing in it, so the view keeps the near + side whole and gives up half of what the region withholds on the far side, which covers its + scrollbar. + """ + box = read_item(region).rect + if box is None: + return None + + room = dpg.get_item_state(region)["content_region_avail"] + return Rect( + x=box.x, + y=box.y, + width=box.width - (box.width - room[0]) / 2, + height=box.height - (box.height - room[1]) / 2, + ) + + +def read_visible_box(item: Item) -> Optional[Rect]: + """The part of ``item``'s box the regions around it and the viewport leave in view. Runs on the render thread.""" + box = read_item(item).rect + for region in enclosing_regions(item): + view = read_region_view(region) + if box is None or view is None: + return None + + box = box.overlap(view) + + return box.overlap(read_client_area()) if box is not None else None + + +def read_table(table: Item) -> Tuple[Tuple[Tuple[Item, ...], ...], ...]: + """The items inside each cell of each row of ``table``, row by row. Runs on the render thread. + + A table keeps its columns in one slot of children and its rows in another, and each row holds + one cell per column, whatever item the cell carries. + """ + rows = dpg.get_item_info(table)["children"][1] + return tuple( + tuple(tuple(dpg.get_item_info(cell)["children"][1]) for cell in dpg.get_item_info(row)["children"][1]) + for row in rows + ) diff --git a/tests/suite/screens/dearpygui/items/texts.py b/tests/suite/screens/dearpygui/items/texts.py new file mode 100644 index 000000000..884c3b4ac --- /dev/null +++ b/tests/suite/screens/dearpygui/items/texts.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.items.types import MENU_ITEM_TYPE, TEXT_TYPE, Item + + +@dataclass(frozen=True) +class EntryReading: + """One entry of a popup menu: its words, where it stands in the viewport, and whether it answers.""" + + label: str + point: Point + enabled: bool + + +def read_popup_entries(popup: Item) -> Tuple[EntryReading, ...]: + """The menu entries a popup window holds, in order. Runs on the render thread. + + DearPyGui reports an entry's position inside the popup alone, so the entry stands where the + popup does, moved by that position. + """ + corner = dpg.get_item_pos(popup) + entries: List[EntryReading] = [] + for child in dpg.get_item_children(popup, 1): + if dpg.get_item_info(child)["type"] != MENU_ITEM_TYPE: + continue + + position = dpg.get_item_state(child)["pos"] + entries.append( + EntryReading( + label=read_label(child), + point=Point(x=round(corner[0] + position[0]), y=round(corner[1] + position[1])), + enabled=bool(dpg.get_item_configuration(child).get("enabled", True)), + ) + ) + + return tuple(entries) + + +def read_label(item: Item) -> str: + """The label ``item`` carries. Runs on the render thread.""" + return str(dpg.get_item_label(item) or "") + + +def read_shown_texts(container: Item) -> Tuple[str, ...]: + """The words of every shown text item under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + return _texts(container, shown_only=True) + + +def read_texts(container: Item) -> Tuple[str, ...]: + """The words of every text item under ``container``, shown or waiting to be, such as a tooltip's. + + Runs on the render thread. + """ + return _texts(container, shown_only=False) + + +def read_visible_text(words: str) -> bool: + """Whether a text item reading ``words`` was drawn in the last frame, such as a tooltip standing open. + + Runs on the render thread. + """ + return any( + dpg.get_item_info(item)["type"] == TEXT_TYPE + and dpg.get_value(item) == words + and bool(dpg.is_item_visible(item)) + for item in dpg.get_all_items() + ) + + +def read_shown_labels(container: Item, item_type: str) -> Tuple[str, ...]: + """The labels of every shown item of ``item_type`` under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + labels: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == item_type: + labels.append(read_label(item)) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(labels) + + +def _texts(container: Item, *, shown_only: bool) -> Tuple[str, ...]: + texts: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if shown_only and not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == TEXT_TYPE: + texts.append(str(dpg.get_value(item))) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(texts) + + +def find_labelled( + container: Item, + label: str, + *, + item_type: str, +) -> Item: + """The first item of ``item_type`` under ``container`` whose label reads ``label``. Runs on the render thread. + + A label may carry padding around its words, so the words are compared with the padding stripped. + + Raises: + LookupError: If no such item stands under ``container``. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + info = dpg.get_item_info(item) + if info["type"] == item_type and read_label(item).strip() == label.strip(): + return item + + for children in info["children"].values(): + pending.extend(children) + + raise LookupError(f"No {item_type} labelled '{label}' stands under {container!r}") diff --git a/tests/suite/screens/dearpygui/items/types.py b/tests/suite/screens/dearpygui/items/types.py new file mode 100644 index 000000000..fd941fed0 --- /dev/null +++ b/tests/suite/screens/dearpygui/items/types.py @@ -0,0 +1,10 @@ +from typing import Final, Union + +Item = Union[int, str] +WINDOW_TYPE: Final[str] = "mvAppItemType::mvWindowAppItem" +CHILD_WINDOW_TYPE: Final[str] = "mvAppItemType::mvChildWindow" +TREE_NODE_TYPE: Final[str] = "mvAppItemType::mvTreeNode" +MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" +MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" +TEXT_TYPE: Final[str] = "mvAppItemType::mvText" +BUTTON_TYPE: Final[str] = "mvAppItemType::mvButton" diff --git a/tests/suite/screens/dearpygui/items/viewport.py b/tests/suite/screens/dearpygui/items/viewport.py new file mode 100644 index 000000000..ca918f879 --- /dev/null +++ b/tests/suite/screens/dearpygui/items/viewport.py @@ -0,0 +1,40 @@ +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Point, Rect + + +def read_viewport() -> Rect: + """The viewport's client area, placed where it stands on the screen. Runs on the render thread.""" + corner = dpg.get_viewport_pos() + return Rect( + x=corner[0], + y=corner[1], + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) + + +def read_viewport_decorated() -> bool: + """Whether the window wears the system's title bar and frame. Runs on the render thread.""" + return bool(dpg.is_viewport_decorated()) + + +def read_viewport_title() -> str: + """The title the viewport's window carries in its title bar. Runs on the render thread.""" + return str(dpg.get_viewport_title()) + + +def read_pointer() -> Point: + """Where the pointer stands in the viewport, as the application last saw it. Runs on the render thread.""" + position = dpg.get_mouse_pos(local=False) + return Point(x=round(position[0]), y=round(position[1])) + + +def read_client_area() -> Rect: + """The viewport's client area in the coordinates its items report. Runs on the render thread.""" + return Rect( + x=0, + y=0, + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) diff --git a/tests/suite/screens/dearpygui/keys.py b/tests/suite/screens/dearpygui/keys.py new file mode 100644 index 000000000..b040ce0fc --- /dev/null +++ b/tests/suite/screens/dearpygui/keys.py @@ -0,0 +1,95 @@ +from typing import Dict, Final + +from Xlib import XK + +IMGUI_TAB: Final[int] = 512 +IMGUI_LEFT: Final[int] = 513 +IMGUI_RIGHT: Final[int] = 514 +IMGUI_UP: Final[int] = 515 +IMGUI_DOWN: Final[int] = 516 +IMGUI_PAGE_UP: Final[int] = 517 +IMGUI_PAGE_DOWN: Final[int] = 518 +IMGUI_HOME: Final[int] = 519 +IMGUI_END: Final[int] = 520 +IMGUI_INSERT: Final[int] = 521 +IMGUI_DELETE: Final[int] = 522 +IMGUI_BACKSPACE: Final[int] = 523 +IMGUI_SPACE: Final[int] = 524 +IMGUI_ENTER: Final[int] = 525 +IMGUI_ESCAPE: Final[int] = 526 +IMGUI_LEFT_CTRL: Final[int] = 527 +IMGUI_LEFT_SHIFT: Final[int] = 528 +IMGUI_LEFT_ALT: Final[int] = 529 +IMGUI_DIGIT_ZERO: Final[int] = 536 +IMGUI_LETTER_A: Final[int] = 546 +IMGUI_F1: Final[int] = 572 +DIGIT_COUNT: Final[int] = 10 +LETTER_COUNT: Final[int] = 26 +FUNCTION_KEY_COUNT: Final[int] = 12 + +NAMED_KEYSYMS: Final[Dict[int, str]] = { + IMGUI_TAB: "Tab", + IMGUI_LEFT: "Left", + IMGUI_RIGHT: "Right", + IMGUI_UP: "Up", + IMGUI_DOWN: "Down", + IMGUI_PAGE_UP: "Prior", + IMGUI_PAGE_DOWN: "Next", + IMGUI_HOME: "Home", + IMGUI_END: "End", + IMGUI_INSERT: "Insert", + IMGUI_DELETE: "Delete", + IMGUI_BACKSPACE: "BackSpace", + IMGUI_SPACE: "space", + IMGUI_ENTER: "Return", + IMGUI_ESCAPE: "Escape", + IMGUI_LEFT_CTRL: "Control_L", + IMGUI_LEFT_SHIFT: "Shift_L", + IMGUI_LEFT_ALT: "Alt_L", +} + +DIGIT_KEYSYMS: Final[Dict[int, str]] = {IMGUI_DIGIT_ZERO + offset: str(offset) for offset in range(DIGIT_COUNT)} + +LETTER_KEYSYMS: Final[Dict[int, str]] = { + IMGUI_LETTER_A + offset: chr(ord("a") + offset) for offset in range(LETTER_COUNT) +} + +FUNCTION_KEYSYMS: Final[Dict[int, str]] = {IMGUI_F1 + offset: f"F{offset + 1}" for offset in range(FUNCTION_KEY_COUNT)} + +KEYSYM_NAMES: Final[Dict[int, str]] = { + **NAMED_KEYSYMS, + **DIGIT_KEYSYMS, + **LETTER_KEYSYMS, + **FUNCTION_KEYSYMS, +} + + +class UnmappedKeyError(LookupError): + """Raised when a scenario presses a key the X keyboard table holds no name for.""" + + +def keysym_of(key: int) -> int: + """The X keysym a press of the Dear ImGui key ``key`` arrives as. + + Dear ImGui numbers its keys in an enumeration of its own, and DearPyGui reports a press under + that number. A few of DearPyGui's own ``mvKey_*`` constants carry older values, which is why the + table names the enumeration's numbers. + + Raises: + UnmappedKeyError: If the table holds no name for ``key``. + """ + name = KEYSYM_NAMES.get(key) + if name is None: + raise UnmappedKeyError(f"The X keyboard table holds no name for the Dear ImGui key {key}") + + return XK.string_to_keysym(name) + + +def key_name(key: int) -> str: + """The name the X keyboard table gives the Dear ImGui key ``key``, for a report a person reads.""" + return KEYSYM_NAMES.get(key, str(key)) + + +def keysym_of_character(character: str) -> int: + """The X keysym typing ``character`` sends; a printable ASCII keysym is its own code point.""" + return ord(character) diff --git a/tests/suite/screens/dearpygui/plots.py b/tests/suite/screens/dearpygui/plots.py new file mode 100644 index 000000000..7a5ae21a1 --- /dev/null +++ b/tests/suite/screens/dearpygui/plots.py @@ -0,0 +1,47 @@ +from functools import partial +from typing import Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge + +PlotValue = Tuple[float, float] + + +def read_plot_pointer() -> PlotValue: + """The value the plot under the pointer puts there, as its last drawn frame read it.""" + x, y = dpg.get_plot_mouse_pos() + return float(x), float(y) + + +def settled_plot_pointer( + bridge: Bridge, + plot: str, + *, + timeout: float, +) -> PlotValue: + """The value ``plot`` puts under the resting pointer, once the plot holds it for two frames running. + + The plot reads the pointer as it draws, so its value trails the pointer by a frame. + + Raises: + ExpectationError: If the pointer never rests on ``plot`` or its value keeps changing. + """ + bridge.expect( + lambda: bool(dpg.is_item_hovered(plot)), + bool, + description=f"the pointer resting on '{plot}'", + timeout=timeout, + ) + return bridge.expect( + partial(_two_frames_of_pointer, bridge), + lambda readings: readings[0] == readings[1], + description=f"the value under the pointer on '{plot}' holding still", + timeout=timeout, + )[1] + + +def _two_frames_of_pointer(bridge: Bridge) -> Tuple[PlotValue, PlotValue]: + first = bridge.ask(read_plot_pointer) + bridge.frames(ONE_FRAME) + return first, bridge.ask(read_plot_pointer) diff --git a/tests/suite/screens/dearpygui/reach.py b/tests/suite/screens/dearpygui/reach.py new file mode 100644 index 000000000..77eee2d3f --- /dev/null +++ b/tests/suite/screens/dearpygui/reach.py @@ -0,0 +1,77 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import ItemReading, read_item +from tests.suite.screens.dearpygui.items.regions import read_visible_box, read_windows +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_client_area + + +class UnreachableError(AssertionError): + """Raised when a scenario reaches for a control a user could not press.""" + + +def reachable(item: Item) -> Rect: + """The box of ``item``, once it stands where a user could press it. Runs on the render thread. + + A control is in reach when it exists, it and every container around it are shown, it was drawn + in the last frame, it answers a press, its box lies inside the viewport, its middle lies in the + part the regions around it leave in view, and no modal window of another tree holds the screen. + + Raises: + UnreachableError: Naming the first condition the control misses. + """ + reading = read_item(item) + refusal = _refusal(reading) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + assert reading.rect is not None + return reading.rect + + +def _refusal(reading: ItemReading) -> Optional[str]: + if not reading.exists: + return "it does not exist" + if not reading.shown: + return "it or a container around it is hidden" + if not reading.visible: + return "it was not drawn in the last frame" + if not reading.enabled: + return "it is disabled" + if reading.rect is None: + return "its kind reports no box to press" + if not read_client_area().contains(reading.rect): + return f"its box {reading.rect} reaches outside the viewport" + + visible = read_visible_box(reading.item) + if visible is None or not visible.holds(reading.rect.center): + return "its middle lies outside the part the regions around it leave in view" + + covering = covering_modal(reading.item) + if covering is not None: + return f"the modal window '{covering}' holds the screen" + + return None + + +def covering_modal(item: Item) -> Optional[str]: + """The modal window of another tree holding the screen over ``item``, if one does.""" + root = _root_window(item) + for window in read_windows(): + if window.shown and window.modal and window.alias != root: + return window.alias + + return None + + +def _root_window(item: Item) -> str: + root: Item = item + parent = dpg.get_item_parent(root) + while parent is not None: + root = parent + parent = dpg.get_item_parent(root) + + return str(dpg.get_item_alias(root)) diff --git a/tests/suite/screens/dearpygui/recording.py b/tests/suite/screens/dearpygui/recording.py new file mode 100644 index 000000000..2911ff9e6 --- /dev/null +++ b/tests/suite/screens/dearpygui/recording.py @@ -0,0 +1,66 @@ +import threading +from dataclasses import dataclass +from typing import Callable, Final, Generic, List, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, RenderThread + +ReadingT = TypeVar("ReadingT") +NO_FRAME: Final[int] = 0 + + +@dataclass(frozen=True) +class FrameReading(Generic[ReadingT]): + """What one reading found right after one frame was drawn.""" + + frame: int + value: ReadingT + + +class FrameRecording(Generic[ReadingT]): + """A reading taken right after every frame between a start and a stop, on the render thread. + + An expectation reads once a frame until it holds, and so misses a state lasting a single frame. + A recording keeps every frame's reading, so a scenario asserting that something never happened + between two gestures reads each frame that passed. + """ + + def __init__( + self, + render_thread: RenderThread, + reading: Callable[[], ReadingT], + ) -> None: + self._render_thread = render_thread + self._reading = reading + self._readings: List[FrameReading[ReadingT]] = [] + self._stopped = threading.Event() + self._lock = threading.Lock() + + def start(self) -> None: + """Takes the first reading after the next frame and goes on reading after every frame.""" + self._render_thread.post(self._take, frames=ONE_FRAME) + + def stop(self) -> None: + """Ends the recording; the readings taken so far stay available.""" + self._stopped.set() + + @property + def readings(self) -> Tuple[FrameReading[ReadingT], ...]: + """Every reading taken so far, in frame order, each with the frame it followed.""" + with self._lock: + return tuple(self._readings) + + def values(self) -> List[ReadingT]: + """The value of every reading taken so far, in frame order.""" + return [reading.value for reading in self.readings] + + def _take(self) -> None: + if self._stopped.is_set(): + return + + reading = FrameReading(frame=dpg.get_frame_count(), value=self._reading()) + with self._lock: + self._readings.append(reading) + + self._render_thread.post(self._take, frames=ONE_FRAME) diff --git a/tests/suite/screens/dearpygui/screenshot.py b/tests/suite/screens/dearpygui/screenshot.py new file mode 100644 index 000000000..13a14f9a0 --- /dev/null +++ b/tests/suite/screens/dearpygui/screenshot.py @@ -0,0 +1,61 @@ +import threading +from collections.abc import Buffer +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import Bridge + +WRITE_TIMEOUT_SECONDS: Final[float] = 10.0 + + +def capture(bridge: Bridge, path: Path) -> Path: + """Saves the next frame the application draws as a PNG at ``path``, and returns the path. + + DearPyGui writes the picture while drawing a frame, so the capture waits for the frame to land. + """ + path.parent.mkdir(parents=True, exist_ok=True) + bridge.ask(lambda: dpg.output_frame_buffer(file=str(path))) + bridge.expect( + lambda: bridge.ask(path.exists), + bool, + description=f"the screenshot {path} to be written", + timeout=WRITE_TIMEOUT_SECONDS, + ) + return path + + +@dataclass(frozen=True) +class DrawnFrame: + """One frame as DearPyGui drew it: its size, and its pixels row after row, each red, green, blue and alpha as + 32-bit fractions. + """ + + width: int + height: int + pixels: bytes + + +def drawn_frame(bridge: Bridge) -> DrawnFrame: + """The next frame the application draws. + + DearPyGui hands the frame over while drawing it, so the reading waits for it to land. + """ + landed: List[DrawnFrame] = [] + taken = threading.Event() + + def take(_sender: object, frame: Buffer) -> None: + landed.append( + DrawnFrame( + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + pixels=memoryview(frame).tobytes(), + ) + ) + taken.set() + + bridge.ask(lambda: dpg.output_frame_buffer(callback=take)) + bridge.expect(taken.is_set, bool, description="the frame to be handed over", timeout=WRITE_TIMEOUT_SECONDS) + return landed[0] diff --git a/tests/suite/screens/dearpygui/semantic.py b/tests/suite/screens/dearpygui/semantic.py new file mode 100644 index 000000000..81d7fe027 --- /dev/null +++ b/tests/suite/screens/dearpygui/semantic.py @@ -0,0 +1,86 @@ +from inspect import Parameter, signature +from typing import Any, Callable, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.items.reading import ItemReading, read_item +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.reach import UnreachableError, covering_modal + +CallbackRunner = Callable[..., object] +POSITIONAL: Final = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + + +def invoke( + item: Item, + run: CallbackRunner, +) -> None: + """Runs ``item``'s callback as DearPyGui runs it for a press, through ``run``. Runs on the render thread. + + This is the gesture for a control DearPyGui reports a position alone for, such as a menu entry. The + control must exist, stand shown, answer a press and sit outside any other tree's modal window. + The callback receives as many of the sender, the value and the user data as it declares, which + is what DearPyGui hands it. + + Raises: + UnreachableError: Naming what keeps a user from the control. + """ + refusal = _refusal(read_item(item)) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + callback = dpg.get_item_callback(item) + if callback is None: + raise UnreachableError(f"{item!r} answers no press: it carries no callback") + + arguments = (item, dpg.get_value(item), dpg.get_item_user_data(item)) + run(callback, *_declared(callback, arguments)) + + +def choose( + item: Item, + value: Any, + run: CallbackRunner, +) -> None: + """Sets ``item`` to ``value`` and runs its callback as a pick of that option does. Runs on the render thread. + + This is the gesture for a choice DearPyGui draws as one item, such as a radio button's options or a + combo's entries. The choice must stand in reach the way :func:`invoke` asks. + + Raises: + UnreachableError: Naming what keeps a user from the choice. + """ + refusal = _refusal(read_item(item)) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + callback = dpg.get_item_callback(item) + if callback is None: + raise UnreachableError(f"{item!r} answers no choice: it carries no callback") + + dpg.set_value(item, value) + arguments = (item, value, dpg.get_item_user_data(item)) + run(callback, *_declared(callback, arguments)) + + +def _refusal(reading: ItemReading) -> Optional[str]: + if not reading.exists: + return "it does not exist" + if not reading.shown: + return "it or a container around it is hidden" + if not reading.enabled: + return "it is disabled" + + covering = covering_modal(reading.item) + if covering is not None: + return f"the modal window '{covering}' holds the screen" + + return None + + +def _declared(callback: Callable[..., object], arguments: Sequence[Any]) -> Sequence[Any]: + parameters = signature(callback).parameters.values() + if any(parameter.kind is Parameter.VAR_POSITIONAL for parameter in parameters): + return arguments + + return arguments[: sum(1 for parameter in parameters if parameter.kind in POSITIONAL)] diff --git a/tests/suite/screens/dearpygui/windows.py b/tests/suite/screens/dearpygui/windows.py new file mode 100644 index 000000000..696b3ac54 --- /dev/null +++ b/tests/suite/screens/dearpygui/windows.py @@ -0,0 +1,70 @@ +from typing import Final, Iterator + +from Xlib import X +from Xlib.display import Display +from Xlib.protocol.event import ClientMessage +from Xlib.xobject.drawable import Window + +PROCESS_PROPERTY: Final[str] = "_NET_WM_PID" +PROTOCOLS_PROPERTY: Final[str] = "WM_PROTOCOLS" +DELETE_PROTOCOL: Final[str] = "WM_DELETE_WINDOW" +CLIENT_MESSAGE_FORMAT: Final[int] = 32 +UNUSED_FIELD: Final[int] = 0 + + +class MissingWindowError(AssertionError): + """Raised when no window on the display belongs to the process a scenario drives.""" + + +class WindowManager: + """What a window manager does to the application's window on a scenario's display. + + The scenario's X server runs no window manager, so the request a manager sends when a user + presses the close button of a title bar is sent from here, to the window carrying the + application's process id. + """ + + def __init__( + self, + display_name: str, + process_id: int, + ) -> None: + self._display = Display(display_name) + self._process_id = process_id + + def request_close(self) -> None: + """Asks the application's window to close, as the close button of its title bar does. + + Raises: + MissingWindowError: If no window on the display carries the application's process id. + """ + window = self._own_window() + message = ClientMessage( + window=window, + client_type=self._display.intern_atom(PROTOCOLS_PROPERTY), + data=( + CLIENT_MESSAGE_FORMAT, + [self._display.intern_atom(DELETE_PROTOCOL), X.CurrentTime, UNUSED_FIELD, UNUSED_FIELD, UNUSED_FIELD], + ), + ) + window.send_event(message, event_mask=X.NoEventMask) + self._display.sync() + + def close(self) -> None: + """Closes the connection to the display.""" + self._display.close() + + def _own_window(self) -> Window: + process_property = self._display.intern_atom(PROCESS_PROPERTY) + for window in _windows_under(self._display.screen().root): + owner = window.get_full_property(process_property, X.AnyPropertyType) + if owner is not None and list(owner.value) == [self._process_id]: + return window + + raise MissingWindowError(f"No window on the display carries the process id {self._process_id}") + + +def _windows_under(window: Window) -> Iterator[Window]: + for child in window.query_tree().children: + yield child + yield from _windows_under(child) diff --git a/tests/suite/screens/dearpygui/xtest.py b/tests/suite/screens/dearpygui/xtest.py new file mode 100644 index 000000000..00586944b --- /dev/null +++ b/tests/suite/screens/dearpygui/xtest.py @@ -0,0 +1,130 @@ +from enum import IntEnum +from typing import Final + +from Xlib import X +from Xlib.display import Display +from Xlib.ext import xtest + +from tests.suite.screens.dearpygui.geometry import Point + +SHIFTED_COLUMN: Final[int] = 1 +BITS_PER_BYTE: Final[int] = 8 + + +class MouseButton(IntEnum): + """A pointer button as the X server numbers it, the two wheel directions included.""" + + LEFT = 1 + MIDDLE = 2 + RIGHT = 3 + WHEEL_UP = 4 + WHEEL_DOWN = 5 + + @property + def mask(self) -> int: + """The bit the pointer's state carries while this button is down.""" + return X.Button1Mask << (self.value - 1) + + +class UntypableCharacterError(LookupError): + """Raised when the display's keyboard map holds no key that types a character.""" + + +class XTestDevice: + """The pointer and the keyboard of one X display, driven through the XTEST extension. + + Each call sends its events and flushes them, so the server holds them before the next frame polls. + The display belongs to the worker, which keeps every event inside the application under test, and + the worker's scenarios take it in turn. The device therefore starts and ends with every key and + button up, so a scenario that failed halfway through a gesture leaves nothing held for the next. + The display turns key auto-repeat off, so a key held across a slow frame arrives as the one + press a person made. + """ + + def __init__(self, display_name: str) -> None: + self._display = Display(display_name) + self._display.change_keyboard_control(auto_repeat_mode=X.AutoRepeatModeOff) + self.release_everything() + + def move(self, point: Point) -> None: + """Moves the pointer to ``point``, in display pixels.""" + xtest.fake_input(self._display, X.MotionNotify, x=point.x, y=point.y) + self._display.sync() + + def button_down(self, button: MouseButton) -> None: + """Presses ``button`` and holds it down.""" + xtest.fake_input(self._display, X.ButtonPress, int(button)) + self._display.sync() + + def button_up(self, button: MouseButton) -> None: + """Releases ``button``.""" + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def click_button( + self, + button: MouseButton, + presses: int, + ) -> None: + """Presses and releases ``button`` ``presses`` times in one go. + + Dear ImGui reads each press and each release on a frame of its own, so a press stands down for a + single frame and the presses land two frames apart, the closest a display brings them. + """ + for _ in range(presses): + xtest.fake_input(self._display, X.ButtonPress, int(button)) + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def tap_key(self, keysym: int) -> None: + """Presses and releases the key typing ``keysym`` in one go. + + Dear ImGui reads the press on one frame and the release on the next, so the key stands down for a + single frame at any frame rate, too briefly to repeat. + """ + keycode = self._keycode(keysym) + xtest.fake_input(self._display, X.KeyPress, keycode) + xtest.fake_input(self._display, X.KeyRelease, keycode) + self._display.sync() + + def key_down(self, keysym: int) -> None: + """Presses the key typing ``keysym`` and holds it down.""" + xtest.fake_input(self._display, X.KeyPress, self._keycode(keysym)) + self._display.sync() + + def key_up(self, keysym: int) -> None: + """Releases the key typing ``keysym``.""" + xtest.fake_input(self._display, X.KeyRelease, self._keycode(keysym)) + self._display.sync() + + def needs_shift(self, keysym: int) -> bool: + """Whether typing ``keysym`` takes Shift, which is where the keyboard map shifts it.""" + keycode = self._keycode(keysym) + return self._display.keycode_to_keysym(keycode, 0) != keysym and ( + self._display.keycode_to_keysym(keycode, SHIFTED_COLUMN) == keysym + ) + + def release_everything(self) -> None: + """Lets go of every key and button the display holds down, whichever gesture pressed it.""" + for index, byte in enumerate(self._display.query_keymap()): + for bit in range(BITS_PER_BYTE): + if byte & (1 << bit): + xtest.fake_input(self._display, X.KeyRelease, index * BITS_PER_BYTE + bit) + + mask = self._display.screen().root.query_pointer().mask + for button in MouseButton: + if mask & button.mask: + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def close(self) -> None: + """Lets go of everything still held and closes the connection to the display.""" + self.release_everything() + self._display.close() + + def _keycode(self, keysym: int) -> int: + keycode = self._display.keysym_to_keycode(keysym) + if keycode == 0: + raise UntypableCharacterError(f"The keyboard map holds no key for the keysym {keysym:#x}") + + return int(keycode) diff --git a/tests/suite/screens/environment.py b/tests/suite/screens/environment.py new file mode 100644 index 000000000..b3524a208 --- /dev/null +++ b/tests/suite/screens/environment.py @@ -0,0 +1,89 @@ +import re +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Mapping, Tuple + +from sampletones_shared.application import SAMPLETONES_ENV_PREFIX +from tests.suite.screens.dearpygui.display import DisplayBackend, ScreenSize +from tests.suite.screens.paths import ARTIFACTS_DIRECTORY, HOME_FOLDER, NO_BUS_FILE, REPORTS_FILE + +DISPLAY_BACKEND_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_DISPLAY" +ARTIFACTS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_ARTIFACTS" +REPORT_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPORT" +STRICT_HISTORY_VARIABLE: Final[str] = f"{SAMPLETONES_ENV_PREFIX}STRICT_HISTORY" +STRICT_HISTORY: Final[str] = "true" +NO_INPUT_METHOD: Final[str] = "@im=none" +DROPPED_PREFIXES: Final[Tuple[str, ...]] = ("PYTEST_", "WAYLAND_") +UNSAFE_CHARACTERS: Final[re.Pattern[str]] = re.compile(r"[^A-Za-z0-9_.-]+") +SCREEN_SIZE: Final[ScreenSize] = ScreenSize(width=1600, height=1000) +DEFAULT_DISPLAY_BACKEND: Final[DisplayBackend] = DisplayBackend.XVFB + + +def display_backend(environment: Mapping[str, str]) -> DisplayBackend: + """The X server a run draws on: Xvfb unless ``SAMPLETONES_SCREENS_DISPLAY`` names another.""" + return DisplayBackend(environment.get(DISPLAY_BACKEND_VARIABLE, DEFAULT_DISPLAY_BACKEND)) + + +@dataclass(frozen=True) +class ScenarioFolders: + """Where one scenario's run keeps its files: the home its application lives in, and its reports. + + Attributes: + root: The scenario's folder under the run's artifacts, kept after the run for a reader. + home: The home directory the application reads and writes as the user's own. + reports: The file the child process writes its pytest reports to. + """ + + root: Path + home: Path + reports: Path + + @classmethod + def of(cls, nodeid: str) -> "ScenarioFolders": + """The folders of the scenario ``nodeid`` names, under a name a file system accepts.""" + root = ARTIFACTS_DIRECTORY / UNSAFE_CHARACTERS.sub("_", nodeid) + return cls( + root=root, + home=root / HOME_FOLDER, + reports=root / REPORTS_FILE, + ) + + def prepare(self) -> None: + """Clears what an earlier run of the scenario left, and lays out an empty home.""" + shutil.rmtree(self.root, ignore_errors=True) + self.home.mkdir(parents=True) + + +def child_environment( + base: Mapping[str, str], + folders: ScenarioFolders, + *, + display: str, +) -> Dict[str, str]: + """The environment a scenario's process starts under: ``base``, pointed at the scenario's own world. + + The home and every XDG directory lie inside the scenario's folder, so settings, session state + and the documents folder start empty and stay apart from the user's. The display is the + worker's own server, the session bus address leads nowhere, and the input method is off, so + everything a scenario does stays on its own display and in its own home. + """ + environment = {name: value for name, value in base.items() if not name.startswith(DROPPED_PREFIXES)} + home = folders.home + environment.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + "XDG_DOCUMENTS_DIR": str(home / "Documents"), + "DISPLAY": display, + "DBUS_SESSION_BUS_ADDRESS": f"unix:path={folders.root / NO_BUS_FILE}", + "XMODIFIERS": NO_INPUT_METHOD, + STRICT_HISTORY_VARIABLE: STRICT_HISTORY, + ARTIFACTS_VARIABLE: str(folders.root), + REPORT_VARIABLE: str(folders.reports), + } + ) + return environment diff --git a/tests/suite/screens/holds/__init__.py b/tests/suite/screens/holds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/holds/base.py b/tests/suite/screens/holds/base.py new file mode 100644 index 000000000..176043c9f --- /dev/null +++ b/tests/suite/screens/holds/base.py @@ -0,0 +1,27 @@ +from typing import List, Protocol + + +class Hold(Protocol): + """Work a scenario keeps under way until it lets go, so a gesture lands while the work runs.""" + + def release(self) -> None: + """Lets the work carry on to its end.""" + + +class Holds: + """Every hold a scenario put on the application's work, let go together before the scenario leaves. + + Leaving waits for work in flight, so the holds are released before the exit is asked for. + """ + + def __init__(self) -> None: + self._holds: List[Hold] = [] + + def add(self, hold: Hold) -> None: + """Puts ``hold`` among the holds released together.""" + self._holds.append(hold) + + def release_all(self) -> None: + """Releases every hold that was added.""" + for hold in self._holds: + hold.release() diff --git a/tests/suite/screens/holds/constants.py b/tests/suite/screens/holds/constants.py new file mode 100644 index 000000000..350e25b0a --- /dev/null +++ b/tests/suite/screens/holds/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +RELEASE_POLL_SECONDS: Final[float] = 0.05 diff --git a/tests/suite/screens/holds/conversion.py b/tests/suite/screens/holds/conversion.py new file mode 100644 index 000000000..6cef58362 --- /dev/null +++ b/tests/suite/screens/holds/conversion.py @@ -0,0 +1,94 @@ +import time +from functools import partial +from pathlib import Path +from typing import AbstractSet, Sequence + +import pytest + +import sampletones_core.reconstructions.converter.converter as converter_module +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.progress import STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, announce +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.types.path import Pathlike +from tests.suite.conversion import COUNTED_STAGES, FAKE_FRAMES, HALFWAY +from tests.suite.screens.holds.constants import RELEASE_POLL_SECONDS +from tests.suite.screens.holds.signal import ReleaseSignal + + +class HeldReconstructor: + """A reconstructor that walks a run's stages, stops halfway through matching until released, and builds + nothing. + + It decides when the run lands, never what it computes: the walk reports the stages a real + reconstruction reports, and a run left with nothing built writes no file. While held it goes on + announcing the halfway mark, which is where a run the reader stops unwinds, and it waits for as + long as the scenario holds it. + + It travels to a worker with the job, the way the real one does, so it is built from a + configuration and the channels the same way, with the release named beside them. + """ + + def __init__( + self, + config: Config, + channels: AbstractSet[ChannelName], + release_path: Path, + ) -> None: + self.config = config + self.channels = channels + self.release_path = release_path + + def reconstruct( + self, + paths: Sequence[Pathlike], + stems_config: StemsConfig, + *, + report: ReconstructionReporter, + ) -> None: + """Walks the stages of a reconstruction, holding halfway through matching, and answers with nothing + built. + + Raises: + OperationCanceled: If the run is withdrawn while it is under way. + """ + del paths, stems_config + for stage in ReconstructionStage: + if stage not in COUNTED_STAGES: + announce(report, stage, STAGE_BEGUN, WHOLE_STAGE) + continue + + for frame in range(FAKE_FRAMES + 1): + announce(report, stage, frame, FAKE_FRAMES) + if stage == ReconstructionStage.MATCHING and frame == HALFWAY: + self._hold(report, stage, frame) + + def _hold( + self, + report: ReconstructionReporter, + stage: ReconstructionStage, + frame: int, + ) -> None: + while not self.release_path.exists(): + announce(report, stage, frame, FAKE_FRAMES) + time.sleep(RELEASE_POLL_SECONDS) + + +class ConversionHold: + """Stands in for the converter's reconstructor with one that holds every run halfway until released.""" + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the converter's reconstructor with one that holds every run halfway through matching.""" + monkeypatch.setattr( + converter_module, + "Reconstructor", + partial(HeldReconstructor, release_path=self._signal.path), + ) + + def release(self) -> None: + """Lets every held run carry on to its end.""" + self._signal.release() diff --git a/tests/suite/screens/holds/export.py b/tests/suite/screens/holds/export.py new file mode 100644 index 000000000..9ab1a4664 --- /dev/null +++ b/tests/suite/screens/holds/export.py @@ -0,0 +1,82 @@ +import itertools +import threading +from typing import Callable, Final + +import pytest + +from sampletones_application.services.export.reporter import ExportProgressReporter +from sampletones_application.services.result import ServiceProgress +from sampletones_core.exports.stage import ExportStage +from tests.suite.screens.holds.constants import RELEASE_POLL_SECONDS + +FIRST_REPORT: Final[int] = 0 + + +class ExportHold: + """Holds every export at one report of its stages, until the scenario releases it. + + A run reports each stage it reaches and then asks whether it is still wanted. The hold answers + that question at the report it stands at, once the export's window has heard the stage, so the + window shows the run under way. While held, the run still hears a cancel and unwinds the way a + cancel unwinds a real run. Released, every run goes on to its end until the scenario holds again. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._held_report = FIRST_REPORT + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Wraps the export reporter so each run asks its question through the hold.""" + build = ExportProgressReporter.__init__ + + def held( + reporter: ExportProgressReporter, + emit: Callable[[ServiceProgress[ExportStage]], None], + withdrawn: Callable[[], bool], + ) -> None: + build(reporter, emit, self._gate(withdrawn)) + + monkeypatch.setattr(ExportProgressReporter, "__init__", held) + + def waiting(self) -> int: + """How many runs stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + """Lets every held run, and every one that starts later, go on to its end.""" + self._released.set() + + def hold_at(self, report: int) -> None: + """Holds every run from now on at its report numbered ``report``, counted from 0. + + The earlier reports pass. + """ + self._held_report = report + self._released.clear() + + def _gate(self, withdrawn: Callable[[], bool]) -> Callable[[], bool]: + """The question one run asks after each report; it waits at the held report until released or + withdrawn. + """ + reports = itertools.count() + + def asked() -> bool: + if next(reports) == self._held_report: + self._wait(withdrawn) + + return withdrawn() + + return asked + + def _wait(self, withdrawn: Callable[[], bool]) -> None: + with self._lock: + self._waiting += 1 + + while not self._released.wait(RELEASE_POLL_SECONDS) and not withdrawn(): + continue + + with self._lock: + self._waiting -= 1 diff --git a/tests/suite/screens/holds/regeneration.py b/tests/suite/screens/holds/regeneration.py new file mode 100644 index 000000000..3fc9acf2d --- /dev/null +++ b/tests/suite/screens/holds/regeneration.py @@ -0,0 +1,74 @@ +import threading +from typing import AbstractSet, Optional + +import pytest + +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.services.result import ServiceError +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +class RegenerationHold: + """Holds every rebuild of an edited channel on its worker before it computes, until the scenario releases + it. + + The rebuild waits where the real one runs, so the application draws and answers while the edit + is on its way, and once released the real rebuild computes the edit's result. The hold lets + every rebuild through from its release on, until the scenario holds again. A scenario standing + for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service + reports the way it reports a rebuild that raised. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._failure: Optional[Exception] = None + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the service's rebuild with one that waits for the release before it computes.""" + rebuild = RegenerationService._run + + def held( + service: RegenerationService, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + self._wait() + if self._failure is not None: + service._emit(ServiceError(exception=self._failure)) + return + + rebuild(service, reconstruction, channel_name, features, heard) + + monkeypatch.setattr(RegenerationService, "_run", held) + + def waiting(self) -> int: + """How many rebuilds stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + """Lets every held rebuild, and every one asked for later, carry on.""" + self._released.set() + + def hold_again(self) -> None: + """Holds every rebuild asked for from now on, as the hold did before its release.""" + self._released.clear() + + def fail(self, failure: Exception) -> None: + """Lets every held rebuild, and every one asked for after, go as ``failure``.""" + self._failure = failure + self._released.set() + + def _wait(self) -> None: + with self._lock: + self._waiting += 1 + + self._released.wait() + with self._lock: + self._waiting -= 1 diff --git a/tests/suite/screens/holds/scan.py b/tests/suite/screens/holds/scan.py new file mode 100644 index 000000000..18645f830 --- /dev/null +++ b/tests/suite/screens/holds/scan.py @@ -0,0 +1,71 @@ +import time +from pathlib import Path +from typing import Final, Iterator + +import pytest + +import sampletones_application.logic.main.sources.scan as scan_module +from tests.suite.screens.holds.constants import RELEASE_POLL_SECONDS +from tests.suite.screens.holds.signal import ReleaseSignal + +HELD_ENTRY: Final[str] = "held-entry.txt" + + +class ScanHold: + """Stands in for the walk a folder scan reads, holding every scan once the tree is read until released. + + The walk meets every entry the folder holds, and then goes on meeting an entry that is no + recording, one every ``interval`` seconds, as a tree of thousands of other files would, until the + scenario lets it end. A reader's Stop is checked between entries, so it is heard while the scan + is held, as soon as the next entry comes. + """ + + def __init__( + self, + signal: ReleaseSignal, + *, + interval: float, + ) -> None: + self._signal = signal + self._interval = interval + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the folder walk of a scan with the held one.""" + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + """Lets the held walk end.""" + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + """Yields every entry below ``root``, then one more entry every ``interval`` seconds until released.""" + yield from root.rglob("*") + while not self._signal.is_released(): + yield root / HELD_ENTRY + time.sleep(self._interval) + + +class WindingDownScanHold: + """Stands in for the walk a folder scan reads, keeping it past its last entry until released. + + The walk meets every entry the folder holds and then waits for the scenario, meeting no further entry. A + reader's Stop is checked between entries, so a stopped read winds down for as long as the scenario holds + it, and a folder asked for meanwhile arrives while it winds down on any machine. + """ + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the folder walk of a scan with the held one.""" + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + """Lets the held walk end.""" + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + """Yields every entry below ``root``, then waits for the release before the walk ends.""" + yield from root.rglob("*") + while not self._signal.is_released(): + time.sleep(RELEASE_POLL_SECONDS) diff --git a/tests/suite/screens/holds/signal.py b/tests/suite/screens/holds/signal.py new file mode 100644 index 000000000..710e0e1c1 --- /dev/null +++ b/tests/suite/screens/holds/signal.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ReleaseSignal: + """A file whose appearance lets held work carry on, which a worker process sees as well as the scenario.""" + + path: Path + + def release(self) -> None: + """Creates the file, which lets the held work carry on.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.touch() + + def is_released(self) -> bool: + """Whether the file exists, which is whether the work was let go.""" + return self.path.exists() diff --git a/tests/suite/screens/keyboard.py b/tests/suite/screens/keyboard.py new file mode 100644 index 000000000..c04e0f4cc --- /dev/null +++ b/tests/suite/screens/keyboard.py @@ -0,0 +1,23 @@ +from typing import Final + +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.modifiers import MODIFIER_KEYS, Modifier +from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut +from tests.suite.screens.dearpygui.hand import Hand + +LEFT_KEY: Final[int] = 0 +PRIMARY_COMBINATION: Final[int] = 0 + + +def primary_combination(shortcut: Shortcut) -> KeyCombination: + """The combination an action displays, which is the one a person presses.""" + return shortcut.combinations()[PRIMARY_COMBINATION] + + +def press_combination(hand: Hand, combination: KeyCombination) -> None: + """Presses ``combination`` on the real keyboard: its modifiers on the left side, then its key. + + Modifiers go down in the order a combination displays them, which is the order a person holds them. + """ + modifiers = [MODIFIER_KEYS[modifier][LEFT_KEY] for modifier in Modifier if modifier in combination.modifiers] + hand.press_key(combination.key, modifiers=modifiers) diff --git a/tests/suite/screens/palettes.py b/tests/suite/screens/palettes.py new file mode 100644 index 000000000..747f6fc6a --- /dev/null +++ b/tests/suite/screens/palettes.py @@ -0,0 +1,35 @@ +from typing import Final, FrozenSet, Sequence, Tuple + +from sampletones_application.paths import PALETTES_DIRECTORY +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.palette import Palette +from tests.suite.screens.dearpygui.items.colors import rounded_color + +Color = Tuple[float, ...] +FULL_STEP: Final[float] = 255.0 + + +def shipped_palettes() -> PaletteCatalog: + """The palettes this build ships, the ones Display settings offers.""" + return PaletteCatalog.load(PALETTES_DIRECTORY) + + +def token_color(palette: Palette, token: str) -> Color: + """The color ``token`` names in ``palette``, as DearPyGui holds it.""" + return in_fractions(palette.colors[token]) + + +def in_fractions(color: Sequence[float]) -> Color: + """A color given in whole steps, as DearPyGui holds it: each part a fraction of the full step.""" + return rounded_color(tuple(part / FULL_STEP for part in color)) + + +def shared_colors(catalog: PaletteCatalog) -> FrozenSet[Color]: + """Every color all the shipped palettes state alike under one token, which no palette swap changes.""" + palettes = tuple(catalog.palettes.values()) + first = palettes[0] + return frozenset( + token_color(first, token) + for token in first.colors + if all(palette.colors.get(token) == first.colors[token] for palette in palettes) + ) diff --git a/tests/suite/screens/paths.py b/tests/suite/screens/paths.py new file mode 100644 index 000000000..558ba4a7c --- /dev/null +++ b/tests/suite/screens/paths.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.source import REPOSITORY_ROOT + +SCREENS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "screens" +SCREEN_DRIVER_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" +DEARPYGUI_LAYER_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" / "dearpygui" +ARTIFACTS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "screens" +HOME_FOLDER: Final[str] = "home" +REPORTS_FILE: Final[str] = "reports.jsonl" +NO_BUS_FILE: Final[str] = "no-bus" +FAILURE_SCREENSHOT: Final[str] = "failure.png" diff --git a/tests/suite/screens/plugin/__init__.py b/tests/suite/screens/plugin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/plugin/constants.py b/tests/suite/screens/plugin/constants.py new file mode 100644 index 000000000..9fe0a5c67 --- /dev/null +++ b/tests/suite/screens/plugin/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +import pytest + +from tests.suite.screens.dearpygui.display import VirtualDisplay + +DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() diff --git a/tests/suite/screens/plugin/fixtures.py b/tests/suite/screens/plugin/fixtures.py new file mode 100644 index 000000000..bc7a564ea --- /dev/null +++ b/tests/suite/screens/plugin/fixtures.py @@ -0,0 +1,173 @@ +import os +from pathlib import Path +from typing import Final, Iterator + +import pytest + +from sampletones_application.application import Application +from sampletones_application.config.profile import UserProfile +from tests.suite.screens.application.boundaries import Boundaries +from tests.suite.screens.application.running import ScreenApplication +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.audio import OutputDevice, OutputRecord, provide_output_device +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import TableHighlights +from tests.suite.screens.boundaries.reveals import FileManagerStandIn +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.display import VirtualDisplay +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.windows import WindowManager +from tests.suite.screens.dearpygui.xtest import XTestDevice +from tests.suite.screens.environment import ARTIFACTS_VARIABLE, SCREEN_SIZE, display_backend +from tests.suite.screens.holds.base import Holds +from tests.suite.screens.paths import FAILURE_SCREENSHOT +from tests.suite.screens.plugin.constants import DISPLAY_KEY +from tests.suite.screens.plugin.hold_fixtures import screen_holds +from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 +STAND_IN_PROGRAMS: Final[str] = "programs" +REVEALS_FILE: Final[str] = "reveals.log" +DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() + + +@pytest.fixture +def world() -> World: + """What the scenario's home holds as the application starts; a scenario overrides it to seed its own.""" + return lived_in_world() + + +@pytest.fixture +def startup() -> Startup: + """The documents the application opens as it starts; a scenario overrides it to open its own.""" + return Startup( + reconstruction=None, + project=None, + ) + + +@pytest.fixture +def output_device() -> OutputDevice: + """The output the scenario's machine offers; a scenario overrides it to start with none.""" + return OutputDevice.SILENT + + +@pytest.fixture +def screen_render_thread() -> QueueRenderThread: + """The queue-backed render thread the scenario's application and bridge share.""" + return QueueRenderThread() + + +@pytest.fixture +def screen_bridge(screen_render_thread: QueueRenderThread) -> Bridge: + """The bridge through which a scenario asks the render thread for readings and gestures.""" + return Bridge(screen_render_thread, answer_timeout=ANSWER_TIMEOUT_SECONDS) + + +@pytest.fixture +def screen_boundaries( + output_device: OutputDevice, + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> Boundaries: + """Puts the scenario's stand-ins between the application and the desktop before it starts.""" + dialogs = ScriptedFileDialogs() + dialogs.install(monkeypatch) + errors = ErrorRecords() + errors.install() + artifacts = Path(os.environ[ARTIFACTS_VARIABLE]) + file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE) + file_manager.install(monkeypatch) + spawns = SpawnGuard() + spawns.install() + spawns.allow(file_manager.folder) + highlights = TableHighlights() + highlights.install(monkeypatch) + output = OutputRecord() + output.install(monkeypatch) + provide_output_device( + output_device, + home=Path.home(), + monkeypatch=monkeypatch, + ) + return Boundaries( + dialogs=dialogs, + errors=errors, + spawns=spawns, + holds=screen_holds, + file_manager=file_manager, + highlights=highlights, + output=output, + ) + + +@pytest.fixture +def screen_application( + world: World, + startup: Startup, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> ScreenApplication: + """SampleToNES as ``sampletones open`` starts it, on the scenario's display and in its home.""" + profile = UserProfile.user() + world.write(profile) + application = Application( + profile=profile, + reconstruction_path=startup.reconstruction, + project_path=startup.project, + ) + return ScreenApplication( + application, + render_thread=screen_render_thread, + bridge=screen_bridge, + boundaries=screen_boundaries, + failure_screenshot=Path(os.environ[ARTIFACTS_VARIABLE]) / FAILURE_SCREENSHOT, + state_path=profile.state, + ) + + +@pytest.fixture +def screen( + screen_application: ScreenApplication, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> Iterator[Screen]: + """The application the scenario drives, with a user's hand on its display.""" + device = XTestDevice(os.environ["DISPLAY"]) + window_manager = WindowManager(os.environ["DISPLAY"], os.getpid()) + yield Screen( + bridge=screen_bridge, + render_thread=screen_render_thread, + hand=Hand(screen_bridge, device), + window_manager=window_manager, + language=screen_application.language, + shortcuts=screen_application.shortcuts, + dialogs=screen_boundaries.dialogs, + errors=screen_boundaries.errors, + file_manager=screen_boundaries.file_manager, + highlights=screen_boundaries.highlights, + output=screen_boundaries.output, + artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), + ) + window_manager.close() + device.close() + + +def _worker_display(config: pytest.Config) -> str: + """The display this worker's scenarios draw on, started with the first of them.""" + name = config.stash.get(DISPLAY_NAME_KEY, None) + if name is not None: + return name + + display = VirtualDisplay(display_backend(os.environ), SCREEN_SIZE) + name = display.start() + config.stash[DISPLAY_KEY] = display + config.stash[DISPLAY_NAME_KEY] = name + return name diff --git a/tests/suite/screens/plugin/hold_fixtures.py b/tests/suite/screens/plugin/hold_fixtures.py new file mode 100644 index 000000000..22cadbdf1 --- /dev/null +++ b/tests/suite/screens/plugin/hold_fixtures.py @@ -0,0 +1,74 @@ +import os +from pathlib import Path +from typing import Final + +import pytest + +from tests.suite.screens.environment import ARTIFACTS_VARIABLE +from tests.suite.screens.holds.base import Holds +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.holds.export import ExportHold +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.holds.scan import ScanHold +from tests.suite.screens.holds.signal import ReleaseSignal + +CONVERSION_RELEASE_FILE: Final[str] = "release-conversion" +SCAN_RELEASE_FILE: Final[str] = "release-scan" +SCAN_ENTRY_SECONDS: Final[float] = 0.05 + + +@pytest.fixture +def screen_holds() -> Holds: + """The work the scenario holds under way, let go before it leaves.""" + return Holds() + + +@pytest.fixture +def conversion_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ConversionHold: + """Holds every conversion the scenario starts halfway through matching, until the scenario releases it.""" + hold = ConversionHold(ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / CONVERSION_RELEASE_FILE)) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def scan_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ScanHold: + """Holds every folder scan the scenario starts once its tree is read, until the scenario releases it.""" + hold = ScanHold( + ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / SCAN_RELEASE_FILE), + interval=SCAN_ENTRY_SECONDS, + ) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def regeneration_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> RegenerationHold: + """Holds every rebuild an edit of a channel asks for, until the scenario releases it.""" + hold = RegenerationHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def export_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ExportHold: + """Holds every export at its first report, until the scenario releases it.""" + hold = ExportHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold diff --git a/tests/suite/screens/plugin/hooks.py b/tests/suite/screens/plugin/hooks.py new file mode 100644 index 000000000..5718f1bf7 --- /dev/null +++ b/tests/suite/screens/plugin/hooks.py @@ -0,0 +1,103 @@ +import os +from pathlib import Path +from typing import Final, Generator, Optional + +import pytest + +from tests.suite.screens.application.running import ScreenApplication +from tests.suite.screens.dearpygui.hosting import host +from tests.suite.screens.dearpygui.isolation import ReportRecorder, run_isolated +from tests.suite.screens.environment import REPORT_VARIABLE, ScenarioFolders, child_environment +from tests.suite.screens.paths import SCREENS_DIRECTORY +from tests.suite.screens.plugin.constants import DISPLAY_KEY +from tests.suite.screens.plugin.fixtures import _worker_display + +CHILD_TIMEOUT_SECONDS: Final[float] = 600.0 +JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 +REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" +SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" + + +class ScreenScenarioError(AssertionError): + """Raised when a screen scenario is written in a shape the tier cannot host.""" + + +def is_screen_item(item: pytest.Item) -> bool: + """Whether ``item`` is a screen scenario, which is any test under ``tests/screens``.""" + return Path(item.path).is_relative_to(SCREENS_DIRECTORY) + + +def is_child_process() -> bool: + """Whether this pytest run is the fresh process one scenario runs in.""" + return REPORT_VARIABLE in os.environ + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_protocol( + item: pytest.Item, + nextitem: Optional[pytest.Item], +) -> Optional[bool]: + """Runs a screen scenario in a process of its own and reports what that process reported.""" + del nextitem + if is_child_process() or not is_screen_item(item): + return None + + folders = ScenarioFolders.of(item.nodeid) + folders.prepare() + environment = child_environment( + os.environ, + folders, + display=_worker_display(item.config), + ) + item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) + for report in run_isolated( + item, + environment=environment, + working_directory=folders.home, + report_path=folders.reports, + timeout=CHILD_TIMEOUT_SECONDS, + ): + item.ihook.pytest_runtest_logreport(report=report) + + item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) + return True + + +def pytest_configure(config: pytest.Config) -> None: + """In a scenario's own process, registers the recorder that writes its reports to the report file.""" + if is_child_process(): + recorder = ReportRecorder(config, Path(os.environ[REPORT_VARIABLE])) + config.pluginmanager.register(recorder, REPORT_RECORDER_NAME) + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Stops the virtual display this worker started, if it started one.""" + display = config.stash.get(DISPLAY_KEY, None) + if display is not None: + display.stop() + + +@pytest.hookimpl(wrapper=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None, Optional[object], Optional[object]]: + """Runs a screen scenario's body on a thread of its own while the application draws on this one.""" + if not is_child_process() or not is_screen_item(pyfuncitem): + return (yield) + + loop = pyfuncitem.funcargs.get(SCREEN_APPLICATION_FIXTURE) + if not isinstance(loop, ScreenApplication): + raise ScreenScenarioError(f"A screen scenario takes the screen fixture: {pyfuncitem.nodeid}") + + original = pyfuncitem.obj + + def hosted(*arguments: object, **keywords: object) -> None: + host( + lambda: original(*arguments, **keywords), + loop, + join_timeout=JOIN_TIMEOUT_SECONDS, + ) + + pyfuncitem.obj = hosted + try: + return (yield) + finally: + pyfuncitem.obj = original diff --git a/tests/suite/screens/render_thread.py b/tests/suite/screens/render_thread.py new file mode 100644 index 000000000..604e5d418 --- /dev/null +++ b/tests/suite/screens/render_thread.py @@ -0,0 +1,46 @@ +import threading +from typing import Callable + +from sampletones_application.utils.callbacks.queue import CallbackQueue + + +class QueueRenderThread: + """SampleToNES's render thread, reached through the queue its own workers post to. + + The queue drains between frames on the thread drawing them, which is where every DearPyGui call + belongs, and it counts a delay in frames drawn. + """ + + def __init__(self) -> None: + self._running = threading.Event() + self._stopped = threading.Event() + + def post( + self, + task: Callable[[], None], + *, + frames: int, + ) -> None: + """Queues ``task`` to run on the render thread once ``frames`` frames are drawn.""" + CallbackQueue.add(task, delay=frames) + + def is_running(self) -> bool: + """Whether the loop is drawing.""" + return self._running.is_set() + + def frames_drawn(self) -> int: + """How many frames the loop has drawn, counted by the queue it drains.""" + return CallbackQueue.current_frame() + + def start(self) -> None: + """Marks the loop as drawing, which is when posted work starts being answered.""" + self._running.set() + + def stop(self) -> None: + """Marks the loop as stopped, so a question still waiting fails at once.""" + self._running.clear() + self._stopped.set() + + def wait_stopped(self, timeout: float) -> bool: + """Waits up to ``timeout`` seconds for the loop to stop, and says whether it did.""" + return self._stopped.wait(timeout) diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py new file mode 100644 index 000000000..f57c839c4 --- /dev/null +++ b/tests/suite/screens/screen.py @@ -0,0 +1,316 @@ +from contextlib import contextmanager +from pathlib import Path +from typing import Callable, Dict, Final, Iterator, Optional, Tuple, TypeVar, Union + +import numpy as np + +from sampletones_application.categories.context import channel_label, generator_label +from sampletones_application.categories.key.text import TextKeyTuple +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_ERROR, + TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, + TAG_GLOBAL_STATUS_BAR, + TAG_GLOBAL_WINDOW_MAIN, +) +from sampletones_application.tags.main import TAG_MAIN_EXPLORER_TREE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager +from sampletones_core.constants.enums import ChannelName, GeneratorName +from tests.suite.scenario import BaseTestScenario, ScenarioStep +from tests.suite.screens.boundaries.audio import OutputRecord +from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import HighlightPlace, TableHighlights +from tests.suite.screens.boundaries.reveals import FileManagerStandIn +from tests.suite.screens.dearpygui.bridge import Bridge, ExpectationError +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import read_item_count +from tests.suite.screens.dearpygui.items.regions import WindowReading, read_windows +from tests.suite.screens.dearpygui.items.texts import read_label, read_visible_text +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_pointer, read_viewport_title +from tests.suite.screens.dearpygui.recording import FrameRecording +from tests.suite.screens.dearpygui.screenshot import capture, drawn_frame +from tests.suite.screens.dearpygui.windows import WindowManager +from tests.suite.screens.keyboard import press_combination, primary_combination +from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.views.audio_settings import AudioSettings +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.context_menu import ContextMenu +from tests.suite.screens.views.display_settings import DisplaySettings +from tests.suite.screens.views.exports import Exports +from tests.suite.screens.views.instructions import Instructions +from tests.suite.screens.views.keyboard_settings import KeyboardSettings +from tests.suite.screens.views.main import Main +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.views.project import Project +from tests.suite.screens.views.reconstructions import Reconstructions +from tests.suite.screens.views.sequencer import Sequencer +from tests.suite.screens.views.tabs import Tabs + +ReadingT = TypeVar("ReadingT") +EXPECT_TIMEOUT_SECONDS: Final[float] = 10.0 +STEP_SEPARATOR: Final[str] = " → " +SCREENSHOT_SUFFIX: Final[str] = ".png" +STATUS_BAR: Final[str] = compose_tag(TAG_GLOBAL_STATUS_BAR, SUF_BUTTON) +PIXEL_CHANNELS: Final[int] = 4 + + +class Screen: + """What a scenario holds: SampleToNES on its display, a user's hand on it, and the views it reads. + + A scenario speaks through views, one per tab, card and dialog. A view knows where its controls + stand and reads what they show, so the scenario states gestures and expectations in a user's + terms. A reading taken while the application still moves is waited for with ``expect``, which + reads once a frame until the expectation holds. + """ + + def __init__( + self, + *, + bridge: Bridge, + render_thread: QueueRenderThread, + hand: Hand, + window_manager: WindowManager, + language: LanguageManager, + shortcuts: ShortcutManager, + dialogs: ScriptedFileDialogs, + errors: ErrorRecords, + file_manager: FileManagerStandIn, + highlights: TableHighlights, + output: OutputRecord, + artifacts: Path, + ) -> None: + self.bridge = bridge + self.hand = hand + self._window_manager = window_manager + self._render_thread = render_thread + self._shortcuts = shortcuts + self._dialogs = dialogs + self._errors = errors + self._file_manager = file_manager + self._highlights = highlights + self._output = output + self._artifacts = artifacts + self._language = language + self.tabs = Tabs(bridge, hand) + self.menu = MenuBar(bridge, hand, language) + self.display_settings = DisplaySettings(bridge, hand, self.menu) + self.keyboard_settings = KeyboardSettings(bridge, hand, self.menu) + self.audio_settings = AudioSettings(bridge, hand) + self.project = Project(bridge, hand, self.menu) + self.main = Main(bridge, hand, language) + self.context_menu = ContextMenu(bridge, hand) + self.explorer = FileTree(bridge, hand, TAG_MAIN_EXPLORER_TREE) + self.reconstructions = Reconstructions(bridge, hand, self.menu) + self.sequencer = Sequencer(bridge, hand, self.menu) + self.instructions = Instructions(bridge, hand) + self.exports = Exports(bridge, hand, self.menu) + self.error_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_ERROR) + self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) + + def expect( + self, + reading: Callable[[], ReadingT], + holds: Callable[[ReadingT], bool], + *, + description: str, + ) -> ReadingT: + """Takes ``reading`` once a frame until ``holds`` accepts it, and returns that reading.""" + return self.bridge.expect( + reading, + holds, + description=description, + timeout=EXPECT_TIMEOUT_SECONDS, + ) + + def words(self, key: Union[str, TextKeyTuple]) -> str: + """What the language file says under ``key``, which is what the application shows the user.""" + return self._language[key] + + def shortcut_words(self, shortcut_id: ShortcutId) -> str: + """How a menu prints the keys of ``shortcut_id`` under the scheme in place.""" + return self._shortcuts.shortcut(shortcut_id).display() + + def frame_pixels(self) -> np.ndarray: + """The next frame drawn, as rows of pixels of red, green, blue and alpha fractions.""" + frame = drawn_frame(self.bridge) + return np.frombuffer(frame.pixels, dtype=np.float32).reshape(frame.height, frame.width, PIXEL_CHANNELS) + + def channel_words(self, channel: ChannelName) -> str: + """The name every display gives ``channel``.""" + return channel_label(self._language, channel) + + def generator_words(self, generator: GeneratorName) -> str: + """The words the application names ``generator`` by wherever it offers one.""" + return generator_label(self._language, generator) + + def status(self) -> str: + """What the status bar along the bottom of the window says.""" + return self.bridge.ask(lambda: read_label(STATUS_BAR)) + + def shows_text(self, words: str) -> bool: + """Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open.""" + return self.bridge.ask(lambda: read_visible_text(words)) + + def expect_item( + self, + reading: Callable[[], Optional[Item]], + *, + description: str, + ) -> Item: + """Takes ``reading`` once a frame until it finds an item, and returns the item. + + Raises: + ExpectationError: If no item is found within the expectation's time. + """ + found = self.expect(reading, lambda value: value is not None, description=description) + if found is None: + raise ExpectationError(f"Expected {description}, and found nothing") + + return found + + def pointer(self) -> Point: + """Where the pointer stands in the viewport, as the application last saw it.""" + return self.bridge.ask(read_pointer) + + def theme_of(self, item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, which is how a control wears a state such as hover.""" + return self.bridge.ask(lambda: read_theme(item)) + + def item_count(self) -> int: + """How many items the interface holds, which a gesture repeated without end leaves where it stood.""" + return self.bridge.ask(read_item_count) + + @contextmanager + def record(self, reading: Callable[[], ReadingT]) -> Iterator[FrameRecording[ReadingT]]: + """Takes ``reading`` on the render thread after every frame drawn while the block runs. + + ``reading`` runs between frames on the render thread, so it reads DearPyGui directly. + """ + recording = FrameRecording(self._render_thread, reading) + recording.start() + try: + yield recording + finally: + recording.stop() + + def frames(self, count: int) -> None: + """Lets ``count`` frames pass, for a scenario whose promise is itself a number of frames.""" + self.bridge.frames(count) + + def capture(self, name: str) -> Path: + """Keeps a picture of the next frame beside the scenario's other files, as evidence of a look.""" + return capture(self.bridge, (self._artifacts / name).with_suffix(SCREENSHOT_SUFFIX)) + + def title(self) -> str: + """The title on the window's title bar, which names the documents open.""" + return self.bridge.ask(read_viewport_title) + + def is_running(self) -> bool: + """Whether the application still draws its window.""" + return self._render_thread.is_running() + + def close_window(self) -> None: + """Closes the application's window from its title bar, the way a window manager asks it to.""" + self._window_manager.request_close() + + def wait_for_exit(self) -> bool: + """Waits for the application to stop, and says whether it did within the expectation's time.""" + return self._render_thread.wait_stopped(EXPECT_TIMEOUT_SECONDS) + + def sound_heard(self) -> int: + """How many samples carrying sound the application has played so far, which grows while anything sounds. + + The count is taken at the output device, so a sound shorter than the frames between two readings counts + all the same. + """ + return self._output.sounding_samples() + + def dialog_requests(self) -> Tuple[DialogRequest, ...]: + """Every file dialog the application opened so far, in order.""" + return self._dialogs.requests + + def revealed(self) -> Tuple[Path, ...]: + """Every path the application asked the desktop's file manager to show, in order.""" + return self._file_manager.shown() + + def table_highlights(self) -> Dict[HighlightPlace, Tuple[float, ...]]: + """Every highlight standing on a table, with the color the application laid it in.""" + return self._highlights.standing() + + def shown_windows(self) -> Tuple[WindowReading, ...]: + """Every window standing on the screen besides the application's main one.""" + return tuple( + window + for window in self.bridge.ask(read_windows) + if window.shown and window.alias != TAG_GLOBAL_WINDOW_MAIN + ) + + def answer_next_dialog( + self, + kind: DialogKind, + path: Optional[Path], + ) -> None: + """Answers the next file dialog of ``kind`` with ``path``, or dismisses it for ``None``.""" + self._dialogs.answer(kind, path) + + def answer_next_save_as( + self, + path: Path, + type_name: str, + ) -> None: + """Answers the next save dialog with ``path``, the offered file type named ``type_name`` picked in + it. + """ + self._dialogs.answer_save_as(path, type_name) + + def claim_error(self, naming: str) -> None: + """Waits for the error a scenario provoked on purpose, one whose message holds ``naming``. + + A claimed error is the application reporting a failure as it should, so the after-checks leave + it out. An error nobody claims still fails the scenario. + """ + self.expect( + lambda: self._errors.claim(naming), + bool, + description=f"an error naming '{naming}'", + ) + + def forgive_known_error(self, naming: str) -> None: + """Claims every error holding ``naming``, which a defect the bugs ledger records provokes by chance. + + A scenario about something else stays quiet about a defect its gestures may meet, while the + scenario reproducing the defect holds it to account. + """ + while self._errors.claim(naming): + continue + + def press_shortcut(self, shortcut_id: ShortcutId) -> None: + """Presses the keys the scheme in place gives ``shortcut_id`` on the real keyboard.""" + press_combination(self.hand, primary_combination(self._shortcuts.shortcut(shortcut_id))) + + def scenario(self, *steps: Callable[["Screen"], None]) -> BaseTestScenario["Screen"]: + """The ordered steps of one scenario, each named after its function in a failure it raises.""" + return BaseTestScenario( + label=STEP_SEPARATOR.join(step.__name__ for step in steps), + build=lambda: self, + steps=[ScenarioStep(label=step.__name__, action=_named(step)) for step in steps], + ) + + +def _named(step: Callable[[Screen], None]) -> Callable[[Screen], None]: + def action(screen: Screen) -> None: + try: + step(screen) + except BaseException as error: + error.add_note(f"In the step '{step.__name__}'") + raise + + return action diff --git a/tests/suite/screens/seeds/__init__.py b/tests/suite/screens/seeds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/seeds/archives.py b/tests/suite/screens/seeds/archives.py new file mode 100644 index 000000000..c678a2162 --- /dev/null +++ b/tests/suite/screens/seeds/archives.py @@ -0,0 +1,96 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Final + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document + +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" +TRUNCATED_FRACTION: Final[int] = 2 +CONFIG_FIELD: Final[str] = "config" + + +class Damage(StrEnum): + """What went wrong with a stored document before the application met it.""" + + OLDER_VERSION = "older version" + FUTURE_VERSION = "future version" + TRUNCATED = "truncated" + FOREIGN_BYTES = "foreign bytes" + + +@dataclass(frozen=True) +class CopiedFile: + """A file laid in the home as it stands elsewhere.""" + + source: Path + destination: Path + + def write(self) -> None: + """Copies the source file to the destination, creating its folder.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.source, self.destination) + + +@dataclass(frozen=True) +class WrittenBytes: + """A file in the home holding exactly ``content``.""" + + destination: Path + content: bytes + + def write(self) -> None: + """Writes the content to the destination, creating its folder.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(self.content) + + +def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: + """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at + ``destination``. + """ + return CopiedFile( + source=archived(kind, ARCHIVED_VERSIONS[kind]), + destination=destination, + ) + + +def archived_library(folder: Path) -> CopiedFile: + """The library a release built, laid in ``folder`` under the name its settings give a library file.""" + source = archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY]) + config = Config(library=InstructionsLibraryConfig.model_validate(stored_document(source)[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return CopiedFile(source=source, destination=folder / key.filename) + + +def damaged_document( + kind: ObjectKind, + damage: Damage, + *, + destination: Path, + older_version: str, + future_version: str, +) -> WrittenBytes: + """The archived document of ``kind`` with ``damage`` done to it, laid at ``destination``. + + A document restated at ``older_version`` or ``future_version`` is whole apart from its version, so + a refusal of it is a refusal of that version alone. + """ + source = archived(kind, ARCHIVED_VERSIONS[kind]) + match damage: + case Damage.OLDER_VERSION: + content = restated_document(source, kind, older_version) + case Damage.FUTURE_VERSION: + content = restated_document(source, kind, future_version) + case Damage.TRUNCATED: + whole = source.read_bytes() + content = whole[: len(whole) // TRUNCATED_FRACTION] + case Damage.FOREIGN_BYTES: + content = FOREIGN_BYTES + + return WrittenBytes(destination=destination, content=content) diff --git a/tests/suite/screens/seeds/constants.py b/tests/suite/screens/seeds/constants.py new file mode 100644 index 000000000..c247e5707 --- /dev/null +++ b/tests/suite/screens/seeds/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +TURNING_LEVELS: Final[int] = 15 diff --git a/tests/suite/screens/seeds/libraries.py b/tests/suite/screens/seeds/libraries.py new file mode 100644 index 000000000..3f1aad606 --- /dev/null +++ b/tests/suite/screens/seeds/libraries.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from sampletones_core.configs import Config +from tests.suite.library import build_served_library + + +@dataclass(frozen=True) +class MiniLibrary: + """A small library built for ``config``, saved where the application looks for that configuration's + library. + + A conversion matches against it in seconds, and the Instructions tab browses it. + """ + + config: Config + + def write(self) -> None: + """Builds the library and saves it where the application looks for it.""" + library, key = build_served_library(self.config) + library.save_data(key, library.data[key]) diff --git a/tests/suite/screens/seeds/projects.py b/tests/suite/screens/seeds/projects.py new file mode 100644 index 000000000..ba9067277 --- /dev/null +++ b/tests/suite/screens/seeds/projects.py @@ -0,0 +1,228 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.famitracker.instrument import instrument_to_fti_bytes +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind +from sampletones_core.project import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from tests.suite.performance import place_instrument +from tests.suite.screens.seeds.constants import TURNING_LEVELS +from tests.suite.screens.seeds.reconstructions import ( + _detached_reconstruction, + _playable_instructions, + _stored_reconstruction, + _turning_instructions, +) + +ARRANGED_FRAMES: Final[int] = 30 +PAD_VOLUME: Final[Tuple[int, ...]] = (12, 8, 4) +PAD_ARPEGGIO: Final[Tuple[int, ...]] = (0, 0, 0) +FIRST_FRAME: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +FIRST_ROW: Final[int] = 0 +LOUDEST_LEVEL: Final[int] = 15 +RELEASING_VOLUME: Final[Tuple[int, ...]] = (15, 8) +RELEASE_POINT: Final[int] = 1 + + +@dataclass(frozen=True) +class StoredProject: + """A project holding the stored reconstruction as the sample ``sample`` and a hand-written + ``instrument``. + """ + + destination: Path + sample: str + instrument: str + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + project.voices.append(Sample(name=self.sample, reconstruction=_stored_reconstruction())) + project.voices.append(Instrument(name=self.instrument)) + self.destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, self.destination) + + +@dataclass(frozen=True) +class ArrangedProject: + """A project with two samples and a hand-written voice, each placed once on the song's first pattern. + + ``line`` is a sample sounding Pulse 1, the triangle and the noise; ``bass`` a sample sounding the + triangle alone; ``pad`` a hand-written voice fading on Pulse 2, its arpeggio flat. The line starts + the pattern on Pulse 1, the hand-written voice comes in on Pulse 2 at ``pad_row``, and the bass on + the triangle at ``bass_row``. The order plays that pattern ``order_frames`` times. + """ + + destination: Path + line: str + bass: str + pad: str + pad_row: int + bass_row: int + order_frames: int + + def write(self) -> None: + """Saves the project to the destination.""" + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, Config())) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, Config())) + pad = Instrument( + name=self.pad, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=PAD_VOLUME), + arpeggio=Envelope[int](items=PAD_ARPEGGIO), + ), + ) + for voice in (line, bass, pad): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=line) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=self.pad_row, sample=pad) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=self.bass_row, sample=bass) + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class OverlongProject: + """A project whose song changes on every channel at every tick, through ``order_frames`` frames. + + Its one sample, ``sample``, plays a new value on each channel at every tick for as long as a + frame lasts, and every frame of the order plays it from the top. Stored tick by tick, the song + outgrows the room an NSF program has; stored with each repeat saved once, it fits. + """ + + destination: Path + sample: str + order_frames: int + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(ONE_FRAME) + sample = Sample( + name=self.sample, reconstruction=_detached_reconstruction(_turning_instructions(ticks), Config()) + ) + project.voices.append(sample) + for channel in ChannelName.items(): + place_instrument(project, channel_name=channel, row_index=FIRST_ROW, sample=sample) + + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class TwoTuningsProject: + """A project whose sample ``line`` was converted at the default tuning, and ``bass`` with A4 at + ``a4_frequency`` hertz. + + The line starts the first pattern on Pulse 1 and the bass the triangle, so the song plays both. + """ + + destination: Path + line: str + bass: str + a4_frequency: float + + def write(self) -> None: + """Saves the project to the destination.""" + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + default = Config() + retuned = default.model_copy( + update={"library": default.library.model_copy(update={"a4_frequency": self.a4_frequency})} + ) + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, default)) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, retuned)) + for voice in (line, bass): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=line) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=FIRST_ROW, sample=bass) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class LongEnvelopeProject: + """A project holding two hand-written voices whose volume envelopes run long, both placed on the first + pattern. + + ``long`` fades through ``long_items`` items and ``middling`` through ``middling_items``, neither + of them ending in silence. + """ + + destination: Path + long: str + long_items: int + middling: str + middling_items: int + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + long = _fading_instrument(self.long, self.long_items) + middling = _fading_instrument(self.middling, self.middling_items) + for voice in (long, middling): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=long) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=FIRST_ROW, sample=middling) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class ReleasingInstrumentFile: + """A FamiTracker instrument file of the voice ``name``, whose volume sequence has a release point.""" + + destination: Path + name: str + + def write(self) -> None: + """Writes the instrument file to the destination.""" + sequences = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind} + sequences[SequenceKind.VOLUME] = InstrumentSequence( + kind=SequenceKind.VOLUME, + items=RELEASING_VOLUME, + release_point=RELEASE_POINT, + ) + instrument = Instrument2A03(index=STANDALONE_INSTRUMENT_INDEX, name=self.name, sequences=sequences) + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(instrument_to_fti_bytes(instrument)) + + +def _fading_instrument(name: str, items: int) -> Instrument: + """A hand-written voice whose volume falls from the loudest level through the quieter ones, over and + over. + """ + return Instrument( + name=name, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=tuple(LOUDEST_LEVEL - index % TURNING_LEVELS for index in range(items))), + ), + ) + + +def _repeat_first_frame(project: Project, order_frames: int) -> None: + """Lays ``order_frames`` frames in the order, each playing the patterns the first one plays.""" + for _ in range(order_frames - 1): + project.song.duplicate_frame(FIRST_FRAME) + + +def _save_project(project: Project, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, destination) diff --git a/tests/suite/screens/seeds/reconstructions.py b/tests/suite/screens/seeds/reconstructions.py new file mode 100644 index 000000000..eaeb3270a --- /dev/null +++ b/tests/suite/screens/seeds/reconstructions.py @@ -0,0 +1,159 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Tuple + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions import Reconstruction +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived +from tests.suite.screens.seeds.constants import TURNING_LEVELS +from tests.suite.stems import recorded_from, single_entry_stems_data + +LINE_START_PITCH: Final[int] = 60 +LINE_STEP_FRAMES: Final[int] = 15 +LINE_SPAN: Final[int] = 12 +LINE_VOLUME: Final[int] = 12 +LINE_DUTY_CYCLE: Final[int] = 2 +BASS_PITCH: Final[int] = 45 +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +BEAT_PERIOD: Final[int] = 4 +BEAT_VOLUME: Final[int] = 10 +TURNING_PULSE_PITCH: Final[int] = 40 +TURNING_PULSE_SPAN: Final[int] = 24 +TURNING_SECOND_PITCH: Final[int] = 30 +TURNING_SECOND_SPAN: Final[int] = 31 +TURNING_TRIANGLE_PITCH: Final[int] = 30 +TURNING_TRIANGLE_SPAN: Final[int] = 29 +TURNING_PERIODS: Final[int] = 16 +TURNING_DUTY_CYCLES: Final[int] = 4 +TURNING_SECOND_LEVEL_STEP: Final[int] = 7 +TURNING_NOISE_LEVEL_STEP: Final[int] = 5 +TURNING_SECOND_DUTY_TICKS: Final[int] = 3 + + +@dataclass(frozen=True) +class StoredReconstruction: + """The reconstruction the last release wrote, laid at ``destination`` the way this build writes it. + + It names the recording :func:`stored_recording` lays in the home, so it opens with its source. + """ + + destination: Path + + def write(self) -> None: + """Saves the reconstruction to the destination.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + _stored_reconstruction().save(self.destination) + + +@dataclass(frozen=True) +class PlayableReconstruction: + """A reconstruction sounding a rising line on Pulse 1, a bass on the triangle and a beat on the noise. + + Each recording of ``recordings`` plays the ``frames`` frames once, in turn, so the document + lasts as long as the recordings laid at those paths, one :class:`Recording` of ``frames`` frames + each, and opens with its sources. + """ + + destination: Path + recordings: Tuple[Path, ...] + frames: int + + def write(self) -> None: + """Saves the reconstruction to the destination, naming each recording as a source.""" + instructions = _playable_instructions(self.frames) + reconstruction = Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=self.recordings[:1], + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + if len(self.recordings) > 1: + reconstruction = recorded_from(reconstruction, self.recordings) + + self.destination.parent.mkdir(parents=True, exist_ok=True) + reconstruction.save(self.destination) + + +def _playable_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]: + """The instructions of ``frames`` frames: a rising line, a bass and a beat on three channels.""" + line: List[InstructionUnion] = [ + PulseInstruction( + on=True, + pitch=LINE_START_PITCH + (frame // LINE_STEP_FRAMES) % LINE_SPAN, + volume=LINE_VOLUME, + duty_cycle=LINE_DUTY_CYCLE, + ) + for frame in range(frames) + ] + bass: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH) for _ in range(frames)] + beat: List[InstructionUnion] = [ + NoiseInstruction( + on=frame % BEAT_FRAMES < BEAT_LENGTH, + period=BEAT_PERIOD, + volume=BEAT_VOLUME if frame % BEAT_FRAMES < BEAT_LENGTH else 0, + short=False, + ) + for frame in range(frames) + ] + return {ChannelName.PULSE1: line, ChannelName.TRIANGLE: bass, ChannelName.NOISE: beat} + + +def _turning_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]: + """Every channel sounding a new pitch, level or period at each of ``ticks`` ticks.""" + return { + ChannelName.PULSE1: [ + PulseInstruction( + on=True, + pitch=TURNING_PULSE_PITCH + tick % TURNING_PULSE_SPAN, + volume=1 + tick % TURNING_LEVELS, + duty_cycle=tick % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.PULSE2: [ + PulseInstruction( + on=True, + pitch=TURNING_SECOND_PITCH + tick % TURNING_SECOND_SPAN, + volume=1 + (tick * TURNING_SECOND_LEVEL_STEP) % TURNING_LEVELS, + duty_cycle=(tick // TURNING_SECOND_DUTY_TICKS) % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.TRIANGLE: [ + TriangleInstruction(on=True, pitch=TURNING_TRIANGLE_PITCH + tick % TURNING_TRIANGLE_SPAN) + for tick in range(ticks) + ], + ChannelName.NOISE: [ + NoiseInstruction( + on=True, + period=tick % TURNING_PERIODS, + volume=1 + (tick * TURNING_NOISE_LEVEL_STEP) % TURNING_LEVELS, + short=bool(tick % 2), + ) + for tick in range(ticks) + ], + } + + +def _detached_reconstruction( + instructions: Dict[ChannelName, List[InstructionUnion]], + config: Config, +) -> Reconstruction: + """A reconstruction of ``instructions`` naming no recording, the way a project stores a sample.""" + return Reconstruction.create( + instructions=instructions, + config=config, + coefficient=1.0, + audio_filepath=(), + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + + +def _stored_reconstruction() -> Reconstruction: + """The reconstruction the compatibility corpus keeps for the release that wrote it.""" + return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) diff --git a/tests/suite/screens/seeds/recordings.py b/tests/suite/screens/seeds/recordings.py new file mode 100644 index 000000000..a3deb9d33 --- /dev/null +++ b/tests/suite/screens/seeds/recordings.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import numpy as np +import soundfile + +from sampletones_core.compatibility.kind import ObjectKind +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document + +RECORDING_SAMPLE_RATE: Final[int] = 44100 +RECORDING_AMPLITUDE: Final[float] = 0.5 +STORED_RECORDING_SECONDS: Final[float] = 0.5 +STORED_RECORDING_FREQUENCY: Final[float] = 220.0 +AUDIO_PATH_FIELD: Final[str] = "audio_filepath" + + +@dataclass(frozen=True) +class Recording: + """A recording in the home: a sine tone of ``frequency`` hertz lasting ``seconds``.""" + + destination: Path + seconds: float + frequency: float + + def write(self) -> None: + """Writes the sine tone to the destination as a sound file.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + times = np.arange(round(self.seconds * RECORDING_SAMPLE_RATE)) / RECORDING_SAMPLE_RATE + tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * self.frequency * times) + soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE) + + +def stored_recording() -> Recording: + """The recording the archived reconstruction names, laid where its relative path leads from the home. + + The application runs in the home, so a relative path the document names resolves there. + """ + document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) + return Recording( + destination=Path.cwd() / document[AUDIO_PATH_FIELD], + seconds=STORED_RECORDING_SECONDS, + frequency=STORED_RECORDING_FREQUENCY, + ) diff --git a/tests/suite/screens/steps/__init__.py b/tests/suite/screens/steps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/steps/exports.py b/tests/suite/screens/steps/exports.py new file mode 100644 index 000000000..6e7e9cf8c --- /dev/null +++ b/tests/suite/screens/steps/exports.py @@ -0,0 +1,11 @@ +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project left as it was opened, which goes through at once.""" + forgive_the_hover_race(screen) + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() diff --git a/tests/suite/screens/steps/instructions.py b/tests/suite/screens/steps/instructions.py new file mode 100644 index 000000000..a1f2580f5 --- /dev/null +++ b/tests/suite/screens/steps/instructions.py @@ -0,0 +1,43 @@ +import operator +from functools import partial +from pathlib import Path + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen + + +def library_path(config: Config) -> Path: + """Where the library built for ``config`` is stored, under the name its settings give it.""" + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +def library_row(screen: Screen, path: Path) -> Item: + """The library card's row of the library stored at ``path``, once it is drawn.""" + library = screen.instructions.library + return screen.expect_item(partial(library.row, path.name), description=f"the row of {path.name}") + + +def open_library_row(screen: Screen, row: Item) -> None: + """Opens a library's row, which is the click that loads it, closing it first where it stands open.""" + tree = screen.instructions.library.tree + if tree.is_open(row): + tree.open_by_click(row) + screen.expect(partial(tree.is_open, row), operator.not_, description="the row closed") + + tree.open_by_click(row) + + +def load_library(screen: Screen, config: Config) -> Item: + """Brings the Instructions tab forward and opens the row of the library built for ``config``, which loads + it. + """ + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + row = library_row(screen, library_path(config)) + open_library_row(screen, row) + return row diff --git a/tests/suite/screens/steps/main.py b/tests/suite/screens/steps/main.py new file mode 100644 index 000000000..6c421de91 --- /dev/null +++ b/tests/suite/screens/steps/main.py @@ -0,0 +1,84 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, Sequence + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen + +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 + + +def home_path(name: str) -> Path: + """A path in the home the application was started in, which the explorer stands open on.""" + return Path.cwd() / name + + +def explorer_row(screen: Screen, path: Path) -> Item: + """The explorer's row of ``path``, once it is drawn.""" + return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer, waiting for the button to name the larger run. + + A long list draws only the rows in view, so the button counting the run is what shows a + recording joined it. + """ + converter = screen.main.converter + for path in paths: + before = converter.action() + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(converter.action, before.__ne__, description=f"{path.name} gathered") + screen.expect(lambda: not converter.scan_shown(), bool, description="the folder read") + + +def choose_from_the_row_menu(screen: Screen, path: Path, entry: str) -> None: + """Right-clicks the converter's row of ``path`` and chooses ``entry`` from its menu.""" + row = screen.main.converter.list.row(path) + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") + screen.context_menu.choose(entry) + + +def give_each_its_own_channel(screen: Screen, channels: Dict[Path, ChannelName]) -> None: + """Leaves each gathered recording the one channel ``channels`` names for it.""" + listing = screen.main.converter.list + for path, keep in channels.items(): + for channel in ChannelName: + if listing.channel_ticked(path, channel) != (channel == keep): + listing.tick(path, channel) + screen.expect( + partial(listing.channel_ticked, path, channel), + (channel == keep).__eq__, + description=f"{path.name} {channel}", + ) + + +def convert_alone( + screen: Screen, + recording: Path, + *, + channel: ChannelName, + replacing: Sequence[Path], +) -> None: + """Converts ``recording`` alone on ``channel``, and waits for the question the run's end asks. + + The recordings of ``replacing`` the list holds leave it first, and ``channel`` is the one + channel the run may sound. + """ + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + for gathered in replacing: + if converter.list.has_row(gathered): + converter.list.remove(gathered) + screen.expect(partial(converter.list.has_row, gathered), operator.not_, description=f"{gathered.name} gone") + + gather(screen, recording) + give_each_its_own_channel(screen, {recording: channel}) + converter.press_action() + + screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) diff --git a/tests/suite/screens/steps/project.py b/tests/suite/screens/steps/project.py new file mode 100644 index 000000000..4f257c28f --- /dev/null +++ b/tests/suite/screens/steps/project.py @@ -0,0 +1,53 @@ +import operator +from pathlib import Path + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.project import ProjectContainer +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen + + +def save_new_project(screen: Screen, path: Path) -> None: + """Starts a new project and saves it to ``path`` through File ▸ Save project as.""" + screen.project.create() + save_project_as(screen, path) + + +def save_project_as(screen: Screen, path: Path) -> None: + """Saves the open project to ``path`` through File ▸ Save project as, and dismisses the notice.""" + project = screen.project + screen.answer_next_dialog(DialogKind.SAVE, path) + + project.save_as() + + screen.expect(project.saved_notice.is_shown, bool, description="the project saved notice") + project.saved_notice.confirm() + screen.expect(project.saved_notice.is_shown, operator.not_, description="the notice gone") + + +def retitle_project(screen: Screen, title: str) -> None: + """Types ``title`` into Project properties and confirms it, which leaves the project unsaved.""" + properties = screen.project.properties + properties.open() + screen.expect(properties.is_shown, bool, description="the Project properties dialog") + + properties.retitle(title) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + + +def saved_project_title(path: Path) -> str: + """The title the project file at ``path`` carries.""" + return ProjectContainer.load(path).info.title + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, answering Exit to the question about the project, and waits for the application to stop.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() diff --git a/tests/suite/screens/steps/reconstructions.py b/tests/suite/screens/steps/reconstructions.py new file mode 100644 index 000000000..bce6d1f2c --- /dev/null +++ b/tests/suite/screens/steps/reconstructions.py @@ -0,0 +1,192 @@ +import operator +from pathlib import Path +from typing import Final, List, Optional + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.constants.symbols import TITLE_SEPARATOR +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +APPLICATION_NAME: Final[str] = "global.dialog.title.main_window" +UNTITLED: Final[str] = "global.dialog.label.untitled" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" +REMOVE_RECONSTRUCTION: Final[str] = "reconstructions.browser.label.context_remove_reconstruction" +UNSAVED_MARK: Final[str] = "*" +LOUDEST: Final[int] = 15 +ITEM_SEPARATOR: Final[str] = " " +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" + + +def marked(name: str, *, unsaved: bool) -> str: + """A document's name as the title bar carries it, marked while it holds unsaved changes.""" + return f"{name}{UNSAVED_MARK}" if unsaved else name + + +def titled(screen: Screen, *documents: str) -> str: + """The window title naming ``documents`` after the application, each as the title bar marks it.""" + return TITLE_SEPARATOR.join((screen.words(APPLICATION_NAME), *documents)) + + +def reconstruction_row(screen: Screen, path: Path) -> Item: + """The browser's row of the reconstruction at ``path``, under the heading listing reconstructions by + configuration. + + The heading is opened where it stands closed, so the row is in reach. + """ + browser = screen.reconstructions.browser + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + screen.expect(lambda: browser.is_open(heading), bool, description="the heading open") + + return screen.expect_item(lambda: browser.file_row(path), description=f"the row of {path.name}") + + +def load_from_the_browser(screen: Screen, path: Path) -> None: + """Double-clicks the browser's row of ``path``, which loads it once nothing unsaved stands in the way.""" + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + screen.reconstructions.browser.double_click(reconstruction_row(screen, path)) + + +def expect_open(screen: Screen, path: Path) -> None: + """Waits until the reconstruction stored at ``path`` is the one open.""" + screen.expect( + lambda: screen.reconstructions.shows_open(path), + bool, + description=f"{path.name} open", + ) + + +def edit_envelope( + screen: Screen, + *, + channel: ChannelName, + feature: FeatureKey, + sequence: str, + title: str, +) -> None: + """Types ``sequence`` over a channel's envelope and waits for the edit to land, which marks the title + ``title``. + """ + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + screen.reconstructions.instruments.type_envelope(channel, feature, sequence) + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") + + +def remove_from_the_browser(screen: Screen, path: Path) -> None: + """Removes the reconstruction at ``path`` through its row's menu, answering Remove to the question.""" + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + reconstructions.browser.right_click(reconstruction_row(screen, path)) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") + screen.context_menu.choose(screen.words(REMOVE_RECONSTRUCTION)) + screen.expect(reconstructions.remove_prompt.is_shown, bool, description="the question about removing") + + reconstructions.remove_prompt.confirm() + + screen.expect(reconstructions.remove_prompt.is_shown, operator.not_, description="the question gone") + screen.expect(path.exists, operator.not_, description=f"{path.name} gone") + + +def voice_title(screen: Screen, project: Optional[str], ordinal: int, voice: str, *, unsaved: bool) -> str: + """The window title while a project's sample stands open: the project, marked, and the sample in brackets. + + The sample is named by its place among the voices, counted from zero in hexadecimal, and its name. + """ + name = marked(project or screen.words(UNTITLED), unsaved=unsaved) + return titled(screen, f"{name} [{ordinal:02X}: {voice}]") + + +def raised(level: int) -> int: + """``level`` one step louder, wrapping past the loudest to silence.""" + return (level + 1) % (LOUDEST + 1) + + +def first_raised(sequence: str) -> str: + """A volume sequence as typed, its first level one step louder.""" + first, *rest = sequence.split(ITEM_SEPARATOR) + return ITEM_SEPARATOR.join((str(raised(int(first))), *rest)) + + +def first_level_raised(levels: List[int]) -> List[int]: + """The volume of each frame, the first frame's one step louder.""" + first, *rest = levels + return [raised(first), *rest] + + +def leading(levels: List[int], frames: List[int]) -> List[int]: + """The first of ``levels``, as many as ``frames`` holds: the frames a document had before an edit.""" + return levels[: len(frames)] + + +def stored_levels(path: Path, channel: ChannelName) -> List[int]: + """The volume each frame of a pulse ``channel`` plays at in the reconstruction stored at ``path``.""" + instructions = Reconstruction.load(path).instructions[channel] + return [instruction.volume for instruction in instructions if isinstance(instruction, PulseInstruction)] + + +def raise_the_first_level( + screen: Screen, + channel: ChannelName, + *, + title: str, +) -> str: + """Raises the first volume of ``channel`` one step and waits for the title to read ``title``. + + Returns: + str: The sequence typed. + """ + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + sequence = first_raised( + screen.expect( + lambda: screen.reconstructions.instruments.envelope(channel, FeatureKey.VOLUME), + bool, + description=f"the volume of {channel} drawn", + ) + ) + edit_envelope( + screen, + channel=channel, + feature=FeatureKey.VOLUME, + sequence=sequence, + title=title, + ) + return sequence + + +def converted(recording: Path) -> Path: + """The one reconstruction a run wrote for ``recording``, wherever below the reconstructions folder it + went. + """ + found = list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{recording.stem}{RECONSTRUCTION_SUFFIX}")) + assert len(found) == 1, found + return found[0] + + +def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, channel: ChannelName) -> str: + """Raises the first volume of ``channel`` one step and waits for its rebuild to stand held. + + Returns: + str: The sequence typed. + """ + reconstructions = screen.reconstructions + sequence = first_raised(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME)) + + reconstructions.instruments.type_envelope(channel, FeatureKey.VOLUME, sequence) + + screen.expect(hold.waiting, bool, description="the rebuild held") + return sequence + + +def edited_title(screen: Screen) -> str: + """The window title while the open reconstruction carries unsaved changes.""" + return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) diff --git a/tests/suite/screens/steps/sequencer.py b/tests/suite/screens/steps/sequencer.py new file mode 100644 index 000000000..328249e76 --- /dev/null +++ b/tests/suite/screens/steps/sequencer.py @@ -0,0 +1,59 @@ +from functools import partial +from typing import Final + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen + +EDIT_VOICE: Final[str] = "sequencer.voices.label.context_edit" +HOVER_RACE: Final[str] = "Error executing callback _on_row_hovered" + + +def voice_row(screen: Screen, name: str) -> Item: + """The Voices card's row of the voice ``name``, on the Sequencer tab brought to the front.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices = screen.sequencer.voices + return screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + + +def double_click_voice(screen: Screen, name: str) -> None: + """Double-clicks the voice ``name``, which opens it on the Reconstructions tab.""" + screen.sequencer.voices.edit(voice_row(screen, name)) + + +def open_voice_menu(screen: Screen, name: str) -> None: + """Right-clicks the row of the voice ``name``, which opens its menu.""" + screen.sequencer.voices.right_click(voice_row(screen, name)) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {name}") + + +def open_voice(screen: Screen, name: str) -> None: + """Opens the voice ``name`` on the Reconstructions tab through Edit on its row's menu.""" + open_voice_menu(screen, name) + screen.context_menu.choose(screen.words(EDIT_VOICE)) + + +def forgive_the_hover_race(screen: Screen) -> None: + """Forgives the error a voice row's hover meets once the list has rebuilt the row, which the ledger + records. + """ + screen.forgive_known_error(HOVER_RACE) + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, letting the changed project go at the question about it.""" + forgive_the_hover_race(screen) + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def on_the_sequencer(screen: Screen) -> None: + """Brings the Sequencer tab to the front and waits until the project's voices are listed.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") diff --git a/tests/suite/screens/views/__init__.py b/tests/suite/screens/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/views/audio_settings.py b/tests/suite/screens/views/audio_settings.py new file mode 100644 index 000000000..0c40e9f1e --- /dev/null +++ b/tests/suite/screens/views/audio_settings.py @@ -0,0 +1,70 @@ +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN, + TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB, + TAG_SETTINGS_AUDIO_WINDOW, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.reach import UnreachableError + +COLOR: Final[str] = "color" +MIDDLE: Final[float] = 0.5 + + +class AudioSettings: + """The Audio settings dialog, as far as its master gain: the slider and the line reading its level in + decibels. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_WINDOW)).shown + + def gain(self) -> float: + """The master gain the slider stands at.""" + return float(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN))) + + def decibels(self) -> str: + """What the line under the slider reads.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB))) + + def decibels_color(self) -> Tuple[float, ...]: + """The color the line under the slider is drawn in.""" + return tuple( + float(part) + for part in self._bridge.ask( + lambda: dpg.get_item_configuration(TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB)[COLOR] + ) + ) + + def drag_gain(self, start: float, end: float) -> None: + """Drags the slider from ``start`` to ``end``, each a fraction of its width. + + A fraction beyond 0 or 1 drags past the end of the slider. + + Raises: + UnreachableError: If the slider reports no box. + """ + box = self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN).rect) + if box is None: + raise UnreachableError("The master gain slider reports no box") + + middle = round(box.y + box.height * MIDDLE) + self._hand.drag( + Point(x=round(box.x + box.width * start), y=middle), + Point(x=round(box.x + box.width * end), y=middle), + ) diff --git a/tests/suite/screens/views/bar_graph.py b/tests/suite/screens/views/bar_graph.py new file mode 100644 index 000000000..f227f4e6c --- /dev/null +++ b/tests/suite/screens/views/bar_graph.py @@ -0,0 +1,113 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_GRAPH_PLOT +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.plots import settled_plot_pointer + +NEAR: Final[float] = 0.25 +FAR: Final[float] = 0.75 +BAR_MIDDLE: Final[float] = 0.5 +HOVER_SECONDS: Final[float] = 10.0 + + +class MissingGraphError(AssertionError): + """Raised when a scenario reaches for a bar graph the screen draws nowhere.""" + + +@dataclass(frozen=True) +class PlotMapping: + """How a plot lays its values across the screen, read off two points the pointer rested on. + + A plot draws its axes in straight lines, so two readings fix where any value lands. + """ + + screen: Tuple[Point, Point] + plot: Tuple[Tuple[float, float], Tuple[float, float]] + + def point(self, x: float, y: float) -> Point: + """The viewport point the plot draws the value ``(x, y)`` at.""" + (near_x, near_y), (far_x, far_y) = self.plot + near, far = self.screen + return Point( + x=round(near.x + (x - near_x) * (far.x - near.x) / (far_x - near_x)), + y=round(near.y + (y - near_y) * (far.y - near.y) / (far_y - near_y)), + ) + + +class BarGraph: + """A graph of one envelope drawn as bars, one per item, which a press or a drag sets.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self.plot = compose_tag(tag, SUF_GRAPH_PLOT) + + def mapping(self) -> PlotMapping: + """Rests the pointer at two points of the plot and reads the values the plot puts under them.""" + rect = self._bridge.ask(lambda: read_item(self.plot).rect) + if rect is None: + raise MissingGraphError(f"The plot '{self.plot}' reports no box") + + points = ( + Point(x=round(rect.x + rect.width * NEAR), y=round(rect.y + rect.height * NEAR)), + Point(x=round(rect.x + rect.width * FAR), y=round(rect.y + rect.height * FAR)), + ) + readings: List[Tuple[float, float]] = [] + for point in points: + readings.append(self.value_under(point)) + + return PlotMapping(screen=points, plot=(readings[0], readings[1])) + + def value_under(self, point: Point) -> Tuple[float, float]: + """Rests the pointer at ``point`` and reads the value the plot puts under it.""" + self._hand.move_to(point) + return settled_plot_pointer(self._bridge, self.plot, timeout=HOVER_SECONDS) + + def zoom_in(self, fraction: float, notches: int) -> None: + """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, widening each bar there.""" + rect = self._bridge.ask(lambda: read_item(self.plot).rect) + if rect is None: + raise MissingGraphError(f"The plot '{self.plot}' reports no box") + + self._hand.wheel_at( + Point(x=round(rect.x + rect.width * fraction), y=round(rect.y + rect.height * BAR_MIDDLE)), -notches + ) + + def drag_across( + self, + first: int, + last: int, + *, + value: float, + ) -> None: + """Drags along the height of ``value`` from the bar of item ``first`` to the bar of item ``last``.""" + mapping = self.mapping() + self._hand.drag(mapping.point(first + BAR_MIDDLE, value), mapping.point(last + BAR_MIDDLE, value)) + + def item_under(self, point: Point) -> Tuple[int, float]: + """The item whose bar stands under ``point``, and the value the plot puts there.""" + x, y = self.value_under(point) + return int(x), y + + def drag_item( + self, + index: int, + *, + start: float, + end: float, + ) -> Point: + """Drags the bar of item ``index`` from the value ``start`` to ``end``, and returns where it let go.""" + mapping = self.mapping() + release = mapping.point(index + BAR_MIDDLE, end) + self._hand.drag(mapping.point(index + BAR_MIDDLE, start), release) + return release diff --git a/tests/suite/screens/views/browsers.py b/tests/suite/screens/views/browsers.py new file mode 100644 index 000000000..207572592 --- /dev/null +++ b/tests/suite/screens/views/browsers.py @@ -0,0 +1,129 @@ +from pathlib import Path +from typing import Callable, List, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.structures.tree.node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import find_item +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import TREE_NODE_TYPE, Item +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_CTRL + +NodeTest = Callable[[TreeNode], bool] + + +class FileTree: + """A tree of rows on one of the tabs, each row standing for the node of the tree it was drawn from. + + A row carries its node, so the view finds a row by the file or the library the node names, + and a heading by the words it shows. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tree: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tree = tree + + def file_row(self, path: Path) -> Optional[Item]: + """The first row naming the file or folder at ``path``, if one is drawn.""" + return self._row(lambda node: isinstance(node, FileSystemNode) and node.filepath == path) + + def file_rows(self, path: Path) -> List[Item]: + """Every row naming the file at ``path``, which a browser listing it under several headings draws + again. + """ + + def rows() -> List[Item]: + found: List[Item] = [] + find_item(self._tree, lambda item: _collect(item, path, found)) + return found + + return self._bridge.ask(rows) + + def generator_row(self, generator: GeneratorName) -> Optional[Item]: + """The first row standing for ``generator``, if one is drawn.""" + return self._row(lambda node: isinstance(node, GeneratorNode) and node.generator_name == generator) + + def library_row(self, matches: Callable[[LibraryNode], bool]) -> Optional[Item]: + """The first row standing for a library ``matches`` accepts, if one is drawn.""" + return self._row(lambda node: isinstance(node, LibraryNode) and matches(node)) + + def heading(self, label: str) -> Optional[Item]: + """The first row whose words read ``label``, such as a heading the tree groups its files under.""" + return self._bridge.ask( + lambda: find_item( + self._tree, + lambda item: _is_tree_row(item) and read_label(item).strip() == label.strip(), + ) + ) + + def label(self, row: Item) -> str: + """The words ``row`` reads.""" + return self._bridge.ask(lambda: read_label(row)) + + def is_open(self, row: Item) -> bool: + """Whether ``row`` stands open onto the rows under it.""" + return bool(self._bridge.ask(lambda: dpg.get_value(row))) + + def open_by_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it, which opens or closes a row of a tree opened by a click.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def double_click(self, row: Item) -> None: + """Brings ``row`` into view and double-clicks it.""" + self._hand.scroll_into_view(row) + self._hand.double_click(row) + + def ctrl_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it while holding Ctrl.""" + self._hand.scroll_into_view(row) + self._hand.click_holding(row, [IMGUI_LEFT_CTRL]) + + def right_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it with the right button, which opens its menu.""" + self._hand.scroll_into_view(row) + self._hand.right_click(row) + + def _row(self, matches: NodeTest) -> Optional[Item]: + return self._bridge.ask( + lambda: find_item( + self._tree, + lambda item: _is_tree_row(item) and _node_matches(item, matches), + ) + ) + + +def _is_tree_row(item: Item) -> bool: + return str(dpg.get_item_info(item)["type"]) == TREE_NODE_TYPE + + +def _node_matches(item: Item, matches: NodeTest) -> bool: + user_data = dpg.get_item_user_data(item) + if not isinstance(user_data, tuple) or not user_data: + return False + + node = user_data[0] + return isinstance(node, TreeNode) and bool(matches(node)) + + +def _collect(item: Item, path: Path, found: List[Item]) -> bool: + if _is_tree_row(item) and _node_matches( + item, lambda node: isinstance(node, FileSystemNode) and node.filepath == path + ): + found.append(item) + + return False diff --git a/tests/suite/screens/views/context_menu.py b/tests/suite/screens/views/context_menu.py new file mode 100644 index 000000000..71fefc79b --- /dev/null +++ b/tests/suite/screens/views/context_menu.py @@ -0,0 +1,168 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import TAG_GLOBAL_CONTEXT_WINDOW, TAG_GLOBAL_TAB_INSTRUCTIONS +from sampletones_application.utils.callbacks.queue import CallbackQueue +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point, Rect +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.texts import EntryReading, find_labelled, read_label, read_popup_entries +from tests.suite.screens.dearpygui.items.types import MENU_ITEM_TYPE, MENU_TYPE, Item +from tests.suite.screens.dearpygui.semantic import invoke + +ENTRY_INSET: Final[Point] = Point(x=12, y=8) +CLEAR_OF_THE_TABS: Final[int] = 48 +ENABLED: Final[str] = "enabled" +OPENING_FRAMES: Final[int] = 3 + + +class MissingEntryError(AssertionError): + """Raised when a scenario chooses an entry that the open menu lacks.""" + + +class ContextMenu: + """The menu a right-click opens, shared by every list and tree, with its entries in the order shown. + + An entry is chosen by a click where it stands, which closes the menu the way a person's click + does, and the menu is put away by a click beside it, on the bare stretch of the tab bar past the + last tab. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the menu stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).shown + + def box(self) -> Rect: + """Where the menu stands in the viewport.""" + rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).rect + if rect is None: + raise MissingEntryError("The context menu stands nowhere") + + return rect + + def entries(self) -> List[EntryReading]: + """The entries of the menu, each with its label and where it stands, in the order shown.""" + return list(self._bridge.ask(lambda: read_popup_entries(TAG_GLOBAL_CONTEXT_WINDOW))) + + def labels(self) -> List[str]: + """The labels of the entries of the menu, in the order shown.""" + return [entry.label for entry in self.entries()] + + def submenus(self) -> List[str]: + """The labels of the submenus the menu offers, in the order shown.""" + + def read() -> List[str]: + return [ + read_label(child) + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1) + if dpg.get_item_info(child)["type"] == MENU_TYPE + ] + + return self._bridge.ask(read) + + def submenu(self, label: str) -> List[Tuple[str, bool]]: + """The entries of the submenu reading ``label``, each with whether it answers. + + Raises: + MissingEntryError: If the menu offers no such submenu. + """ + + def read() -> Optional[List[Tuple[str, bool]]]: + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1): + if dpg.get_item_info(child)["type"] == MENU_TYPE and read_label(child) == label: + return [ + (read_label(entry), bool(dpg.get_item_configuration(entry).get(ENABLED, True))) + for entry in dpg.get_item_children(child, 1) + if dpg.get_item_info(entry)["type"] == MENU_ITEM_TYPE + ] + + return None + + found = self._bridge.ask(read) + if found is None: + raise MissingEntryError(f"The context menu offers no submenu '{label}'") + + return found + + def choose_in( + self, + menu_label: str, + entry_label: str, + ) -> None: + """Opens the submenu reading ``menu_label`` with a click on it, and chooses its entry reading + ``entry_label``. + + DearPyGui reports where a submenu's entries stand inside a window of its own that it names + nowhere, so the entry is chosen by running its callback the way a click does. The menu is then + put away by a click beside it, as a click on the entry would put it away. + + Raises: + MissingEntryError: If the menu offers no such submenu. + """ + header = self._submenu_header(menu_label) + self._hand.click_at(Point(x=header.x + ENTRY_INSET.x, y=header.y + ENTRY_INSET.y)) + self._bridge.frames(OPENING_FRAMES) + self._bridge.ask(lambda: invoke(self._submenu_entry(menu_label, entry_label), CallbackQueue.run)) + if self.is_shown(): + self.dismiss() + + def choose(self, label: str) -> None: + """Clicks the entry reading ``label``. + + Raises: + MissingEntryError: If the menu offers no such entry. + """ + for entry in self.entries(): + if entry.label == label: + self._hand.click_at(Point(x=entry.point.x + ENTRY_INSET.x, y=entry.point.y + ENTRY_INSET.y)) + return + + raise MissingEntryError(f"The context menu offers no '{label}': it offers {self.labels()}") + + def _submenu_header(self, label: str) -> Point: + """Where the submenu reading ``label`` stands in the viewport.""" + + def read() -> Optional[Point]: + corner = dpg.get_item_pos(TAG_GLOBAL_CONTEXT_WINDOW) + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1): + if dpg.get_item_info(child)["type"] == MENU_TYPE and read_label(child) == label: + position = dpg.get_item_state(child)["pos"] + return Point(x=round(corner[0] + position[0]), y=round(corner[1] + position[1])) + + return None + + found = self._bridge.ask(read) + if found is None: + raise MissingEntryError(f"The context menu offers no submenu '{label}'") + + return found + + @staticmethod + def _submenu_entry( + menu_label: str, + entry_label: str, + ) -> Item: + """The entry reading ``entry_label`` of the submenu reading ``menu_label``. + + Runs on the render thread. + """ + menu = find_labelled(TAG_GLOBAL_CONTEXT_WINDOW, menu_label, item_type=MENU_TYPE) + return find_labelled(menu, entry_label, item_type=MENU_ITEM_TYPE) + + def dismiss(self) -> None: + """Clicks the bare stretch of the tab bar past the last tab, which puts the menu away.""" + rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS)).rect + if rect is None: + raise MissingEntryError("The last tab stands nowhere to click past it") + + self._hand.click_at(Point(x=round(rect.x + rect.width) + CLEAR_OF_THE_TABS, y=rect.center.y)) diff --git a/tests/suite/screens/views/converter.py b/tests/suite/screens/views/converter.py new file mode 100644 index 000000000..c0a06172c --- /dev/null +++ b/tests/suite/screens/views/converter.py @@ -0,0 +1,299 @@ +from pathlib import Path +from typing import Final, List, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_DIALOG_INFO, SUF_TOOLTIP +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + PRE_MAIN_CONVERTER_STEMS, + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, + TAG_MAIN_CONVERTER_DIALOG_LOAD, + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, + TAG_MAIN_CONVERTER_GROUP, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_CONVERTER_PROGRESS, + TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_TEXT_STATUS, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_SCAN, + TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, + TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, +) +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import find_item, read_item +from tests.suite.screens.dearpygui.items.texts import read_label, read_texts +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.views.prompts import Prompt + +ROW_TEXT: Final[str] = "text" +ROW_GROUP: Final[str] = "group" +ROW_TWISTY: Final[str] = "twisty" +ROW_REMOVE: Final[str] = "button" +ROW_MARK: Final[str] = ".row." +FIRST_LEVEL: Final[int] = 0 +LEVEL_STRIP: Final[str] = "strip" + + +class ConverterList: + """The recordings and folders gathered into the Converter card, one row each. + + A row is named by the path it was gathered from, so a scenario addresses the row of the file + it seeded. A folder's row carries a marker that opens it onto its recordings. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_STEMS) + + def row(self, path: Path) -> str: + """The name of the row gathered from ``path``, which a click picks.""" + return self.tags.row(str(path), ROW_TEXT) + + def has_row(self, path: Path) -> bool: + """Whether a row gathered from ``path`` is drawn.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.row(path)))) + + def label(self, path: Path) -> str: + """The words the row of ``path`` reads.""" + return self._bridge.ask(lambda: read_label(self.row(path))) + + def is_picked(self, path: Path) -> bool: + """Whether the row of ``path`` stands picked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.row(path)))) + + def row_theme(self, path: Path) -> Optional[str]: + """The theme the row of ``path`` wears, or None when it wears none.""" + return self._bridge.ask(lambda: read_theme(self.row(path))) + + def band_theme(self, path: Path) -> Optional[str]: + """The theme the band behind the row of ``path`` wears, or None when it wears none.""" + return self._bridge.ask(lambda: read_theme(self.tags.row(str(path), ROW_GROUP))) + + def rows(self) -> List[str]: + """The names of every row drawn, top to bottom, as the tags they stand under.""" + + def read() -> List[str]: + found: List[str] = [] + find_item(self.tags.well, lambda item: _collect_row(item, found)) + return found + + return self._bridge.ask(read) + + def row_count(self) -> int: + """How many rows are drawn, folders included.""" + return len(self.rows()) + + def channel_box(self, path: Path, channel: ChannelName) -> str: + """The name of the box ticking ``channel`` on the row of ``path``.""" + return self.tags.channel(str(path), channel) + + def channel_ticked(self, path: Path, channel: ChannelName) -> bool: + """Whether ``channel``'s box on the row of ``path`` stands ticked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(path, channel)))) + + def channel_theme(self, path: Path, channel: ChannelName) -> Optional[str]: + """The theme ``channel``'s box on the row of ``path`` wears, or None when it wears none.""" + return self._bridge.ask(lambda: read_theme(self.channel_box(path, channel))) + + def twisty(self, path: Path) -> str: + """The name of the marker that opens the folder gathered from ``path``.""" + return self.tags.row(str(path), ROW_TWISTY) + + def is_open(self, path: Path) -> bool: + """Whether the folder gathered from ``path`` stands open onto its recordings.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.region(str(path))))) + + def remove_button(self, path: Path) -> str: + """The name of the button removing the row of ``path``.""" + return self.tags.row(str(path), ROW_REMOVE) + + def has_levels(self) -> bool: + """Whether the list stands in level bands, which a mix of several recordings draws.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.level(FIRST_LEVEL, LEVEL_STRIP)))) + + def hint_shown(self) -> bool: + """Whether the hint asking for recordings stands on the card.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT)).shown + + def list_shown(self) -> bool: + """Whether the list of rows stands on the card.""" + return self._bridge.ask(lambda: read_item(self.tags.well)).shown + + def pick(self, path: Path) -> None: + """Brings the row into view and clicks its name.""" + self._hand.scroll_into_view(self.row(path)) + self._hand.click(self.row(path)) + + def tick(self, path: Path, channel: ChannelName) -> None: + """Brings the row into view and clicks its box for ``channel``.""" + self._hand.scroll_into_view(self.channel_box(path, channel)) + self._hand.click(self.channel_box(path, channel)) + + def toggle_folder(self, path: Path) -> None: + """Clicks the marker beside a folder's name.""" + self._hand.scroll_into_view(self.twisty(path)) + self._hand.click(self.twisty(path)) + + def remove(self, path: Path) -> None: + """Clicks the row's remove button.""" + self._hand.scroll_into_view(self.remove_button(path)) + self._hand.click(self.remove_button(path)) + + +class MixQuestion: + """The question picking which recordings a mix takes, once more are gathered than a mix holds.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) + + def is_shown(self) -> bool: + """Whether the question stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION)).shown + + def box(self, path: Path) -> str: + """The name of the box ticking the recording at ``path``.""" + return self.tags.row(str(path), SUF_CHECKBOX) + + def name(self, path: Path) -> str: + """The name of the line showing the recording at ``path``.""" + return self.tags.row(str(path), ROW_TEXT) + + def is_ticked(self, path: Path) -> bool: + """Whether the box of the recording at ``path`` stands ticked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.box(path)))) + + def is_live(self, path: Path) -> bool: + """Whether the box answers a click, which a box beyond a full pick stops doing.""" + return self._bridge.ask(lambda: read_item(self.box(path))).enabled + + def is_highlighted(self, path: Path) -> bool: + """Whether the line of the recording at ``path`` stands highlighted.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.name(path)))) + + def count_line(self) -> str: + """What the line counting the recordings picked reads.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT))) + + def can_add(self) -> bool: + """Whether Add answers a click.""" + return self._bridge.ask(lambda: read_item(_add_button())).enabled + + def tick(self, path: Path) -> None: + """Brings the box of the recording at ``path`` into view and clicks it.""" + self._hand.scroll_into_view(self.box(path)) + self._hand.click(self.box(path)) + + def add(self) -> None: + """Clicks Add, which takes the recordings ticked.""" + self._hand.click(_add_button()) + + def cancel(self) -> None: + """Clicks Cancel, which closes the question and takes no recording.""" + self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, SUF_BUTTON)) + + +class Converter: + """The Converter card: the list, the button naming the run, and the run's progress and questions. + + A scenario reaches the list, the mix question, the prompts and the notices through the attributes + of the card. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.list = ConverterList(bridge, hand) + self.mix_question = MixQuestion(bridge, hand) + self.cancel_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_CANCEL) + self.end_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_LOAD) + self.overwrite_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET) + self.replace_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS) + self.notice = Notice(bridge, hand, compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_DIALOG_INFO)) + self.nothing_below_notice = Notice( + bridge, + hand, + compose_tag(TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, SUF_DIALOG_INFO), + ) + + def action(self) -> str: + """What the button under the list reads: the run it starts, or Cancel while one runs.""" + return self._bridge.ask(lambda: read_label(_action_button())) + + def press_action(self) -> None: + """Brings the button under the list into view and clicks it.""" + self._hand.scroll_into_view(_action_button()) + self._hand.click(_action_button()) + + def run_shown(self) -> bool: + """Whether the card shows a run under way: its status line and its progress.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP)).shown + + def status(self) -> str: + """What the status line of the run reads.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STATUS))) + + def progress(self) -> float: + """How far the run has come, as a fraction.""" + return float(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_PROGRESS))) + + def destination(self) -> str: + """The whole path of the folder the run writes into, as the hover over its shortened path shows it.""" + tooltip = compose_tag(TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, SUF_TOOLTIP) + return " ".join(self._bridge.ask(lambda: read_texts(tooltip))) + + def scan_shown(self) -> bool: + """Whether the window reading a folder stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_SCAN)).shown + + def scan_words(self) -> str: + """What the window reading a folder says it is reading.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER))) + + def stop_scan(self) -> None: + """Clicks Stop on the window reading a folder.""" + self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON)) + + +def _add_button() -> str: + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON) + + +def _action_button() -> str: + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ACTION, SUF_BUTTON) + + +def _collect_row(item: Item, found: List[str]) -> bool: + alias = str(dpg.get_item_alias(item)) + if ROW_MARK in alias and alias.startswith(PRE_MAIN_CONVERTER_STEMS) and alias.endswith(f".{ROW_TEXT}"): + found.append(alias) + + return False diff --git a/tests/suite/screens/views/display_settings.py b/tests/suite/screens/views/display_settings.py new file mode 100644 index 000000000..23df1c3a1 --- /dev/null +++ b/tests/suite/screens/views/display_settings.py @@ -0,0 +1,116 @@ +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, + TAG_SETTINGS_DISPLAY_BUTTON_KEEP, + TAG_SETTINGS_DISPLAY_BUTTON_OK, + TAG_SETTINGS_DISPLAY_BUTTON_REVERT, + TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, + TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, + TAG_SETTINGS_DISPLAY_COMBO_PALETTE, + TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, + TAG_SETTINGS_DISPLAY_DIALOG_DISCARD, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt + +ITEMS: Final[str] = "items" + + +class DisplaySettings: + """The Display settings dialog: the window mode, the pacing and the palette, with Cancel and OK.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.discard_prompt = Prompt(bridge, hand, TAG_SETTINGS_DISPLAY_DIALOG_DISCARD) + + def open(self) -> None: + """Opens the dialog from View ▸ Display settings.""" + self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS) + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW)).shown + + def resolution(self) -> str: + """The window size the Resolution list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION))) + + def resolutions(self) -> Tuple[str, ...]: + """The window sizes the Resolution list offers, in order.""" + return tuple( + str(item) + for item in self._bridge.ask(lambda: dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION))[ + ITEMS + ] + ) + + def choose_resolution(self, label: str) -> None: + """Picks the window size reading ``label`` in the Resolution list. + + The window resizes and the countdown starts. + """ + self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, label, CallbackQueue.run)) + + def palette(self) -> str: + """The palette the Palette list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_COMBO_PALETTE))) + + def choose_palette(self, name: str) -> None: + """Picks the palette ``name`` in the Palette list, which repaints the interface at once.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, name, CallbackQueue.run)) + + def vsync(self) -> bool: + """Whether the vertical sync box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC))) + + def toggle_vsync(self) -> None: + """Clicks the vertical sync box, which ticks it or lets it go.""" + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) + + def borderless(self) -> bool: + """Whether the borderless box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS))) + + def toggle_borderless(self) -> None: + """Clicks the borderless box, which changes the window mode and starts the countdown.""" + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS) + + def countdown_shown(self) -> bool: + """Whether the countdown asking to keep a new window mode stands.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN)).shown + + def keep(self) -> None: + """Presses Keep on the countdown.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_KEEP, SUF_BUTTON)) + + def revert(self) -> None: + """Presses Revert on the countdown.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, SUF_BUTTON)) + + def cancel(self) -> None: + """Presses Cancel, which drops the changes and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, SUF_BUTTON)) + + def confirm(self) -> None: + """Presses OK, which keeps the changes and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_OK, SUF_BUTTON)) diff --git a/tests/suite/screens/views/exports.py b/tests/suite/screens/views/exports.py new file mode 100644 index 000000000..1edf75a66 --- /dev/null +++ b/tests/suite/screens/views/exports.py @@ -0,0 +1,201 @@ +from typing import Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + TAG_GLOBAL_DIALOG_PATH_MESSAGE, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + TAG_SETTINGS_EXPORT_GROUP_STAGES, + TAG_SETTINGS_EXPORT_WINDOW, + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_WINDOW, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import read_shown_texts +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.notices import Notice + + +class MissingControlError(AssertionError): + """Raised when a scenario presses a control the screen draws nowhere.""" + + +class ExportProgress: + """The window an export holds the screen with while it runs: the stages it reached, and Cancel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the window stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_EXPORT_WINDOW)).shown + + def stages(self) -> Tuple[str, ...]: + """The stages the window lists, in the order the run reached them.""" + return self._bridge.ask(lambda: read_shown_texts(TAG_SETTINGS_EXPORT_GROUP_STAGES)) + + def cancel(self) -> None: + """Clicks Cancel, which asks the run to stop.""" + self._hand.click(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON)) + + +class NSFWindow: + """The window setting an NSF program up: its channels, its repeat, its level of compression, + its title and file. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the window stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_WINDOW)).shown + + def offers(self, channel: ChannelName) -> bool: + """Whether ``channel``'s box answers a press, which it does for a channel the source sounds.""" + return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled + + def is_ticked(self, channel: ChannelName) -> bool: + """Whether ``channel``'s box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) + + def tick(self, channel: ChannelName) -> None: + """Clicks ``channel``'s box, which ticks it or lets it go.""" + self._hand.click(_channel_box(channel)) + + def press_on(self, channel: ChannelName) -> None: + """Clicks where ``channel``'s box stands, whether or not the box answers.""" + rect = self._bridge.ask(lambda: read_item(_channel_box(channel))).rect + if rect is None: + raise MissingControlError(f"The box of {channel} stands nowhere") + + self._hand.click_at(rect.center) + + def says_a_channel_is_needed(self) -> bool: + """Whether the line asking for at least one channel stands in the window.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)).shown + + def can_export(self) -> bool: + """Whether Export answers a press.""" + return self._bridge.ask(lambda: read_item(_export_button())).enabled + + def press_export(self) -> None: + """Clicks where Export stands, whether or not it answers.""" + rect = self._bridge.ask(lambda: read_item(_export_button())).rect + if rect is None: + raise MissingControlError("Export stands nowhere") + + self._hand.click_at(rect.center) + + def export(self) -> None: + """Clicks Export, which writes the program to the file chosen.""" + self._hand.click(_export_button()) + + def browse(self) -> None: + """Clicks Browse..., which asks for the file the program is written to.""" + self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_BROWSE, SUF_BUTTON)) + + def cancel(self) -> None: + """Clicks Cancel, which closes the window.""" + self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_CANCEL, SUF_BUTTON)) + + def repeat(self) -> str: + """The way of repeating the Repeat list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_COMBO_REPEAT))) + + def choose_repeat(self, label: str) -> None: + """Picks the way of repeating reading ``label`` in the Repeat list.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_REPEAT, label, CallbackQueue.run)) + + def level(self) -> str: + """The level of compression the Level list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_COMBO_SCHEME))) + + def choose_level(self, label: str) -> None: + """Picks the level of compression reading ``label`` in the Level list.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_SCHEME, label, CallbackQueue.run)) + + def loop_frame(self) -> str: + """What the Frame field reads, in hexadecimal.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME))) + + def type_loop_frame(self, frame: str) -> None: + """Types ``frame`` in hexadecimal into the Frame field, in place of what it held.""" + self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, frame) + + def title(self) -> str: + """What the Title field reads.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_TITLE))) + + def type_title(self, title: str) -> None: + """Types ``title`` into the Title field, in place of what it held.""" + self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_TITLE, title) + + +class Exports: + """The ways a project, a reconstruction or an instrument leaves the application as a file. + + A project is exported from File ▸ Export, a reconstruction's channels from Reconstruction ▸ Export + instruments. An export runs under its window, and its outcome stands in a notice: the project's names the + format it was written in, and an instrument's names the file it wrote. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + self.progress = ExportProgress(bridge, hand) + self.nsf = NSFWindow(bridge, hand) + self.project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_MODULE_EXPORTED) + self.file_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_PATH_MESSAGE) + + def export_project(self, item: MenuElements) -> None: + """Chooses File ▸ Export ▸ ``item``.""" + self._menu.choose(MenuElements.GROUP_FILE, item) + + def export_reconstruction(self, item: MenuElements) -> None: + """Chooses Reconstruction ▸ Export instruments ▸ ``item``.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, item) + + def is_offered(self, group: MenuElements, item: MenuElements) -> bool: + """Whether the entry ``item`` of the menu ``group`` answers a press.""" + return self._menu.is_enabled(group, item) + + +def _channel_box(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) + + +def _export_button() -> str: + return compose_tag(TAG_SETTINGS_NSF_BUTTON_EXPORT, SUF_BUTTON) diff --git a/tests/suite/screens/views/history.py b/tests/suite/screens/views/history.py new file mode 100644 index 000000000..564fd6326 --- /dev/null +++ b/tests/suite/screens/views/history.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_HISTORY_BUTTON_REDO, + TAG_SEQUENCER_HISTORY_BUTTON_UNDO, + TAG_SEQUENCER_HISTORY_WINDOW_LIST, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.types import TEXT_TYPE, Item + +TABLE_TYPE: Final[str] = "mvAppItemType::mvTable" +ENTRY_GROUP: Final[int] = 1 +COLOR_KEY: Final[str] = "color" + + +@dataclass(frozen=True) +class HistorySegment: + """One piece of a history line: its words and the color they are drawn in.""" + + words: str + color: Tuple[float, ...] + + +@dataclass(frozen=True) +class HistoryLine: + """One entry of the History card as it reads: its pieces, and whether it is the state in force.""" + + segments: Tuple[HistorySegment, ...] + current: bool + + @property + def words(self) -> str: + """The words of every piece of the line, joined by spaces.""" + return " ".join(segment.words for segment in self.segments) + + +class History: + """The History card of the Sequencer: one line per entry, the newest on top, with Undo and Redo below.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def lines(self) -> Tuple[HistoryLine, ...]: + """The lines the card draws, newest first.""" + return self._bridge.ask(read_history) + + def current(self) -> HistoryLine: + """The line of the entry the project stands at.""" + return next(line for line in self.lines() if line.current) + + def undo(self) -> None: + """Clicks Undo below the list, which steps the project back one entry.""" + self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_UNDO, SUF_BUTTON)) + + def redo(self) -> None: + """Clicks Redo below the list, which steps the project forward one entry.""" + self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_REDO, SUF_BUTTON)) + + +def read_history() -> Tuple[HistoryLine, ...]: + """The lines the History card draws, newest first. Runs on the render thread.""" + tables = [ + child + for child in dpg.get_item_children(TAG_SEQUENCER_HISTORY_WINDOW_LIST, 1) + if dpg.get_item_info(child)["type"] == TABLE_TYPE + ] + if not tables: + return () + + lines: List[HistoryLine] = [] + for row in dpg.get_item_children(tables[0], 1): + selectable, group = dpg.get_item_children(row, 1)[: ENTRY_GROUP + 1] + lines.append(HistoryLine(segments=_segments(group), current=bool(dpg.get_value(selectable)))) + + return tuple(lines) + + +def _segments(group: Item) -> Tuple[HistorySegment, ...]: + """The pieces of one history line, each with its words and color, in the order drawn.""" + return tuple( + HistorySegment( + words=str(dpg.get_value(text)), + color=tuple(float(part) for part in dpg.get_item_configuration(text)[COLOR_KEY]), + ) + for text in dpg.get_item_children(group, 1) + if dpg.get_item_info(text)["type"] == TEXT_TYPE + ) diff --git a/tests/suite/screens/views/instructions.py b/tests/suite/screens/views/instructions.py new file mode 100644 index 000000000..ae6f350e2 --- /dev/null +++ b/tests/suite/screens/views/instructions.py @@ -0,0 +1,94 @@ +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.graphs import SUF_GRAPH_Y_AXIS +from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM, + TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, + TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_PANEL, + TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS, + TAG_INSTRUCTIONS_LIBRARY_TREE, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_value +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.views.waveform import Waveform + +GENERATE_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, SUF_BUTTON) +CANCEL_GENERATION_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, SUF_BUTTON) +SPECTRUM_BANDS = compose_tag(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, SUF_GRAPH_Y_AXIS) +BAR_SERIES_TYPE: Final[str] = "mvAppItemType::mvBarSeries" + + +class Library: + """The Instructions tab's library card: the libraries its folder holds, and the line saying where they stand.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tree = FileTree(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_TREE) + self.rebuild_prompt = Prompt(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION) + self.regenerate_prompt = Prompt(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION) + self.notice = Notice(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_PANEL) + + def row(self, filename: str) -> Optional[Item]: + """The row standing for the library stored under ``filename``, if one is drawn.""" + return self.tree.library_row(lambda node: node.library_key.filename == filename) + + def generate_label(self) -> str: + """What the button generating a library reads: Generate, or Regenerate where the library stands.""" + return self._bridge.ask(lambda: read_label(GENERATE_BUTTON)) + + def generate(self) -> None: + """Clicks the button generating a library, scrolling it into view first.""" + self._hand.scroll_into_view(GENERATE_BUTTON) + self._hand.click(GENERATE_BUTTON) + + def cancel_generation(self) -> None: + """Clicks the button that cancels a generation under way.""" + self._hand.click(CANCEL_GENERATION_BUTTON) + + def status(self) -> str: + """What the line under the tree says about the library in hand.""" + return str(self._bridge.ask(lambda: read_value(TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS))) + + +class Instructions: + """The Instructions tab: the library card, and the waveform and spectrum of the instruction picked in it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self.library = Library(bridge, hand) + self.waveform = Waveform(bridge, hand, TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM) + + def spectrum_bands(self) -> int: + """How many bands the spectrum draws.""" + + def read() -> int: + return sum( + 1 + for child in dpg.get_item_children(SPECTRUM_BANDS, 1) + if dpg.get_item_info(child)["type"] == BAR_SERIES_TYPE + ) + + return self._bridge.ask(read) diff --git a/tests/suite/screens/views/instruments.py b/tests/suite/screens/views/instruments.py new file mode 100644 index 000000000..a48d5a8ac --- /dev/null +++ b/tests/suite/screens/views/instruments.py @@ -0,0 +1,154 @@ +from typing import Final, Optional, Tuple + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON, SUF_GROUP, SUF_TABLE, SUF_TEXT +from sampletones_application.tags.graphs import SUF_GRAPH, SUF_GRAPH_RAW_DATA +from sampletones_application.tags.reconstructions import ( + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, + TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, + TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import read_item, read_selected_tab, read_value +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.bar_graph import BarGraph + +AUDITION_GROUP: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP) + + +class Instruments: + """The instruments card: a tab per channel, each with a graph and a field per envelope it governs.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def bring_forward(self, channel: ChannelName) -> None: + """Clicks the tab of ``channel``.""" + self._hand.click(tab(channel)) + + def front(self) -> str: + """The tag of the tab standing in front.""" + return self._bridge.ask(lambda: read_selected_tab(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR)) + + def tab_label(self, channel: ChannelName) -> str: + """What ``channel``'s tab reads, which names the open hand-written voice on the tab it is edited + under. + """ + return self._bridge.ask(lambda: read_label(tab(channel))) + + def tab_shown(self, channel: ChannelName) -> bool: + """Whether ``channel``'s tab stands on the screen.""" + return self._bridge.ask(lambda: read_item(tab(channel))).shown + + def tab_theme(self, channel: ChannelName) -> Optional[str]: + """The theme ``channel``'s tab wears, which mutes the tab of a channel that plays nothing.""" + return self._bridge.ask(lambda: read_theme(tab(channel))) + + def size(self, channel: ChannelName) -> str: + """What ``channel``'s tab reads as the size of the instrument it exports.""" + return str( + self._bridge.ask( + lambda: read_value(compose_tag(tab(channel), SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE)) + ) + ) + + def rows(self, channel: ChannelName) -> Tuple[FeatureKey, ...]: + """The envelopes ``channel``'s tab draws a row for, top to bottom.""" + + def read() -> Tuple[FeatureKey, ...]: + return tuple(feature for feature in FeatureKey if read_item(_row(channel, feature)).shown) + + return self._bridge.ask(read) + + def field(self, channel: ChannelName, feature: FeatureKey) -> str: + """The name of the field holding the sequence of ``channel``'s ``feature`` envelope.""" + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH_RAW_DATA, SUF_TEXT) + + def envelope(self, channel: ChannelName, feature: FeatureKey) -> str: + """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" + return str(self._bridge.ask(lambda: read_value(self.field(channel, feature)))) + + def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str]: + """The theme the field wears, which marks a sequence refused or one an export shortens.""" + return self._bridge.ask(lambda: read_theme(self.field(channel, feature))) + + def hover_field(self, channel: ChannelName, feature: FeatureKey) -> None: + """Rests the pointer on ``channel``'s ``feature`` field, which puts what the field takes on the + status bar. + """ + field = self.field(channel, feature) + self._hand.scroll_into_view(field) + rect = self._bridge.ask(lambda: read_item(field).rect) + if rect is None: + raise LookupError(f"The field '{field}' stands nowhere") + + self._hand.move_to(rect.center) + + def type_envelope(self, channel: ChannelName, feature: FeatureKey, sequence: str) -> None: + """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses + Enter. + """ + self.bring_forward(channel) + field = self.field(channel, feature) + self._hand.scroll_into_view(field) + self._hand.replace_text(field, sequence) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + def graph(self, channel: ChannelName, feature: FeatureKey) -> BarGraph: + """The graph drawn for ``channel``'s ``feature`` envelope.""" + return BarGraph( + self._bridge, self._hand, compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH) + ) + + def offers_pitch_stepper(self, channel: ChannelName) -> bool: + """Whether ``channel``'s tab offers the stepper setting the pitch its arpeggio is read against.""" + window = compose_tag(tab(channel), SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW) + return self._bridge.ask(lambda: read_item(compose_tag(window, SUF_TABLE))).shown + + def offers_audition(self) -> bool: + """Whether the Audition switch stands, which it does while a hand-written instrument is open.""" + return self._bridge.ask(lambda: read_item(AUDITION_GROUP)).shown + + def audition(self) -> str: + """The generator the Audition switch names.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION))) + + def choose_audition(self, label: str) -> None: + """Picks the generator reading ``label`` in the Audition list.""" + self._bridge.ask(lambda: choose(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, label, CallbackQueue.run)) + + def can_export(self, channel: ChannelName) -> bool: + """Whether ``channel``'s Export instrument button answers.""" + return self._bridge.ask(lambda: read_item(export_button(channel))).enabled + + def export(self, channel: ChannelName) -> None: + """Clicks ``channel``'s Export instrument button.""" + self._hand.scroll_into_view(export_button(channel)) + self._hand.click(export_button(channel)) + + +def tab(channel: ChannelName) -> str: + """The name of the tab of ``channel``.""" + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel) + + +def export_button(channel: ChannelName) -> str: + """The name of the Export instrument button of ``channel``.""" + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab(channel), SUF_BUTTON) + + +def _row(channel: ChannelName, feature: FeatureKey) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GROUP) diff --git a/tests/suite/screens/views/keyboard_settings.py b/tests/suite/screens/views/keyboard_settings.py new file mode 100644 index 000000000..077fa013d --- /dev/null +++ b/tests/suite/screens/views/keyboard_settings.py @@ -0,0 +1,63 @@ +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + PRE_SETTINGS_KEYBINDINGS_ROW, + SUF_SETTINGS_KEYBINDINGS_SHORTCUT, + TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, + TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, + TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, + TAG_SETTINGS_KEYBINDINGS_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt + + +class KeyboardSettings: + """The Keyboard settings dialog: one row per action with the keys it answers to, and Cancel and OK.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.reassign_prompt = Prompt(bridge, hand, TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN) + + def open(self) -> None: + """Opens the dialog from View ▸ Keyboard settings.""" + self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS) + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_KEYBINDINGS_WINDOW)).shown + + def listen_for(self, shortcut_id: ShortcutId) -> None: + """Clicks the keys of ``shortcut_id``'s row, which listens for the next keys pressed.""" + cell = _shortcut_cell(shortcut_id) + self._hand.scroll_into_view(cell) + self._hand.click(cell) + + def keys_of(self, shortcut_id: ShortcutId) -> str: + """What the row of ``shortcut_id`` shows as its keys.""" + return self._bridge.ask(lambda: read_label(_shortcut_cell(shortcut_id))) + + def cancel(self) -> None: + """Presses Cancel, which drops the keys edited and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, SUF_BUTTON)) + + def confirm(self) -> None: + """Presses OK, which puts the keys edited in force.""" + self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, SUF_BUTTON)) + + +def _shortcut_cell(shortcut_id: ShortcutId) -> str: + return compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, shortcut_id.value, SUF_SETTINGS_KEYBINDINGS_SHORTCUT) diff --git a/tests/suite/screens/views/main.py b/tests/suite/screens/views/main.py new file mode 100644 index 000000000..644f42683 --- /dev/null +++ b/tests/suite/screens/views/main.py @@ -0,0 +1,148 @@ +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_COLLAPSE_BODY, + SUF_COLLAPSE_RAIL, + SUF_COLLAPSE_STRIP, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_STEP_BUTTON_LIT, +) +from sampletones_application.tags.main import ( + PRE_MAIN_SOURCE_STEP, + TAG_MAIN_ADVANCED_BUTTON_SELECT_LIBRARY_DIRECTORY, + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PATH_LIBRARY_DIRECTORY_DISPLAY, + TAG_MAIN_CONVERTER_RADIO_MODE, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.main.source import CHANNEL_CAP_STEPS +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import read_texts +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.converter import Converter +from tests.suite.screens.views.source import SourceSettings + +OUTPUT_LABEL_KEYS = { + OutputKind.PER_RECORDING: "main.converter.label.mode_each", + OutputKind.MIXED: "main.converter.label.mode_mixed", +} + + +class Card: + """A card of the interface that folds into its header bar at a click on the bar.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tag = tag + + def is_shown(self) -> bool: + """Whether the card stands on the screen.""" + return self._bridge.ask(lambda: read_item(self.tag)).shown + + def is_collapsed(self) -> bool: + """Whether the card stands folded into its bar, its body put away.""" + return not self._bridge.ask(lambda: read_item(compose_tag(self.tag, SUF_COLLAPSE_BODY))).shown + + def toggle(self) -> None: + """Clicks the card's header bar, which folds an open card and opens a folded one.""" + strip = compose_tag(self.tag, SUF_COLLAPSE_STRIP) + self._hand.scroll_into_view(strip) + self._hand.click(strip) + + def folds_sideways(self) -> bool: + """Whether the card folds into a rail at its side, and not into its bar.""" + return self._bridge.ask(lambda: read_item(self.rail)).exists + + @property + def rail(self) -> str: + """The rail a sideways card folds into, which a click unfolds.""" + return compose_tag(self.tag, SUF_COLLAPSE_RAIL) + + @property + def strip(self) -> str: + """The header bar a click folds the card on.""" + return compose_tag(self.tag, SUF_COLLAPSE_STRIP) + + def unfold_from_the_rail(self) -> None: + """Clicks the rail a sideways card stands folded into, which unfolds it.""" + self._hand.click(self.rail) + + +class Main: + """The Main tab: its cards, the converter's settings and the folders the advanced settings name.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + language: LanguageManager, + ) -> None: + self._bridge = bridge + self._hand = hand + self._language = language + self.advanced = Card(bridge, hand, TAG_MAIN_ADVANCED_PANEL) + self.converter = Converter(bridge, hand) + self.source = SourceSettings(bridge, hand) + + def card(self, tag: str) -> Card: + """A view of the card standing under ``tag``.""" + return Card(self._bridge, self._hand, tag) + + def output(self) -> OutputKind: + """What the converter's output switch stands at.""" + label = self._bridge.ask(lambda: read_value(TAG_MAIN_CONVERTER_RADIO_MODE)) + for output, key in OUTPUT_LABEL_KEYS.items(): + if self._language[key] == label: + return output + + raise LookupError(f"The output switch reads '{label}', which names no output") + + def choose_output(self, output: OutputKind) -> None: + """Picks ``output`` on the converter's output switch.""" + label = self._language[OUTPUT_LABEL_KEYS[output]] + self._bridge.ask(lambda: choose(TAG_MAIN_CONVERTER_RADIO_MODE, label, CallbackQueue.run)) + + def choose_channels_at_once(self, count: int) -> None: + """Clicks the step naming ``count`` channels at once.""" + self._hand.scroll_into_view(_step_tag(count)) + self._hand.click(_step_tag(count)) + + def lit_channels_at_once(self) -> Optional[int]: + """The step of Channels at once that stands lit, if one does.""" + + def read() -> Optional[int]: + for step in CHANNEL_CAP_STEPS: + if read_theme(_step_tag(step)) == TAG_GLOBAL_THEME_STEP_BUTTON_LIT: + return step + + return None + + return self._bridge.ask(read) + + def choose_library_directory(self) -> None: + """Clicks the button that asks for the library folder.""" + button = compose_tag(TAG_MAIN_ADVANCED_BUTTON_SELECT_LIBRARY_DIRECTORY, SUF_BUTTON) + self._hand.scroll_into_view(button) + self._hand.click(button) + + def library_directory(self) -> str: + """The whole path of the library folder, as the hover over its shortened path shows it.""" + tooltip = compose_tag(TAG_MAIN_ADVANCED_PATH_LIBRARY_DIRECTORY_DISPLAY, SUF_TOOLTIP) + return " ".join(self._bridge.ask(lambda: read_texts(tooltip))) + + +def _step_tag(step: int) -> str: + return compose_tag(PRE_MAIN_SOURCE_STEP, str(step), SUF_BUTTON) diff --git a/tests/suite/screens/views/menus.py b/tests/suite/screens/views/menus.py new file mode 100644 index 000000000..961aecaf3 --- /dev/null +++ b/tests/suite/screens/views/menus.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass +from typing import Final, List + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.general import TAG_GLOBAL_TAB_INSTRUCTIONS, TAG_GLOBAL_WINDOW_MAIN +from sampletones_application.utils.callbacks.queue import CallbackQueue +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import find_labelled, read_label +from tests.suite.screens.dearpygui.items.types import MENU_ITEM_TYPE, MENU_TYPE, Item +from tests.suite.screens.dearpygui.items.viewport import read_viewport +from tests.suite.screens.dearpygui.semantic import invoke + +HEADER_INSET: Final[Point] = Point(x=12, y=8) +ENABLED: Final[str] = "enabled" +SHORTCUT: Final[str] = "shortcut" +GROUP_TYPE: Final[str] = "mvAppItemType::mvGroup" +FAR_END_INSET: Final[int] = 30 + + +@dataclass(frozen=True) +class MenuEntry: + """One entry of a menu on the bar: its words, whether it answers, the keys printed beside it, and its check mark.""" + + label: str + enabled: bool + keys: str + checked: bool + + +class MenuBar: + """The menu bar along the top of the window, read by the words its menus show. + + A menu and its entry are found by their labels, which the language file gives, so a scenario + names an entry the way a user reads it. DearPyGui reports a menu entry's position alone, so + choosing one runs its callback the way a click on it does. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + language: LanguageManager, + ) -> None: + self._bridge = bridge + self._hand = hand + self._language = language + + def choose( + self, + group: MenuElements, + item: MenuElements, + ) -> None: + """Chooses the entry ``item`` of the menu ``group``.""" + self._bridge.ask(lambda: invoke(self._entry(group, item), CallbackQueue.run)) + + def open(self, group: MenuElements) -> None: + """Clicks the header of the menu ``group``, which opens its popup.""" + corner = self.header(group) + self._hand.click_at(Point(x=corner.x + HEADER_INSET.x, y=corner.y + HEADER_INSET.y)) + + def header(self, group: MenuElements) -> Point: + """Where the header of the menu ``group`` stands on the bar, its top left corner.""" + corner = self._bridge.ask(lambda: dpg.get_item_state(self._menu(group))["pos"]) + return Point(x=round(corner[0]), y=round(corner[1])) + + def close(self) -> None: + """Clicks the bare far end of the tab bar, clear of every menu's popup, which puts an open menu away.""" + last_tab = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS).rect) + viewport = self._bridge.ask(read_viewport) + if last_tab is None: + raise LookupError("The last tab reports no box to click beside") + + self._hand.click_at(Point(x=round(viewport.width) - FAR_END_INSET, y=last_tab.center.y)) + + def entries(self, group: MenuElements) -> List[MenuEntry]: + """The entries of the menu ``group``, in order: their words, whether they answer, and the keys printed.""" + + def read() -> List[MenuEntry]: + return [ + MenuEntry( + label=read_label(entry), + enabled=bool(dpg.get_item_configuration(entry).get(ENABLED, True)), + keys=str(dpg.get_item_configuration(entry).get(SHORTCUT, "")), + checked=bool(read_value(entry)), + ) + for entry in _menu_items(self._menu(group)) + ] + + return self._bridge.ask(read) + + def is_enabled( + self, + group: MenuElements, + item: MenuElements, + ) -> bool: + """Whether the entry ``item`` of the menu ``group`` answers a press.""" + return self._bridge.ask(lambda: read_item(self._entry(group, item)).enabled) + + def is_checked( + self, + group: MenuElements, + item: MenuElements, + ) -> bool: + """Whether the entry ``item`` of the menu ``group`` carries its check mark.""" + return bool(self._bridge.ask(lambda: read_value(self._entry(group, item)))) + + def _menu(self, group: MenuElements) -> Item: + """The menu ``group`` on the bar. Runs on the render thread.""" + return find_labelled(TAG_GLOBAL_WINDOW_MAIN, self.label(group), item_type=MENU_TYPE) + + def _entry( + self, + group: MenuElements, + item: MenuElements, + ) -> Item: + """The entry ``item`` of the menu ``group``. Runs on the render thread.""" + menu = find_labelled( + TAG_GLOBAL_WINDOW_MAIN, + self.label(group), + item_type=MENU_TYPE, + ) + return find_labelled( + menu, + self.label(item), + item_type=MENU_ITEM_TYPE, + ) + + def label(self, element: MenuElements) -> str: + """The words the menu bar shows for ``element``.""" + return self._language[ + Page.GLOBAL, + Panel.MENU, + TextType.LABEL, + element, + ] + + +def _menu_items(menu: Item) -> List[Item]: + """The entries under ``menu`` in the order drawn, those of its groups among them. Runs on the render thread.""" + found: List[Item] = [] + for child in dpg.get_item_children(menu, 1): + kind = dpg.get_item_info(child)["type"] + if kind == MENU_ITEM_TYPE: + found.append(child) + elif kind == GROUP_TYPE: + found.extend(_menu_items(child)) + + return found diff --git a/tests/suite/screens/views/notices.py b/tests/suite/screens/views/notices.py new file mode 100644 index 000000000..332cad02b --- /dev/null +++ b/tests/suite/screens/views/notices.py @@ -0,0 +1,35 @@ +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.views.prompts import Prompt + + +class Notice: + """A dialog telling the reader something, such as a failure, which the reader acknowledges with OK. + + Each kind of notice composes its windows under a tag of its own, which is the one the view is + given. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self.prompt = Prompt(bridge, hand, tag) + + def is_shown(self) -> bool: + """Whether a notice of this kind stands on the screen.""" + return self.prompt.is_shown() + + def words(self) -> str: + """Everything the notice says, its lines joined by spaces. + + Raises: + MissingPromptError: If no notice of this kind stands on the screen. + """ + return self.prompt.words() + + def dismiss(self) -> None: + """Presses OK.""" + self.prompt.confirm() diff --git a/tests/suite/screens/views/project.py b/tests/suite/screens/views/project.py new file mode 100644 index 000000000..0c4f9b029 --- /dev/null +++ b/tests/suite/screens/views/project.py @@ -0,0 +1,91 @@ +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_PROJECT_OPEN, + TAG_GLOBAL_DIALOG_PROJECT_SAVED, + TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_PROPERTIES_BUTTON_OK, + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_TITLE, + TAG_SETTINGS_PROPERTIES_WINDOW, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt + + +class ProjectProperties: + """The Project properties dialog: the title, the author and the highlights a project carries.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + + def open(self) -> None: + """Opens the dialog from File ▸ Project properties.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES) + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_PROPERTIES_WINDOW)).shown + + def title(self) -> str: + """The title the title field holds.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE))) + + def retitle(self, title: str) -> None: + """Types ``title`` over the title the field holds.""" + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, title) + + def retype_highlights(self, first: int, second: int) -> None: + """Types ``first`` and ``second`` over the fields of the rows the tracker tints.""" + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, str(first)) + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, str(second)) + + def confirm(self) -> None: + """Presses OK, which keeps the typed values and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_PROPERTIES_BUTTON_OK, SUF_BUTTON)) + + +class Project: + """The project as the File menu reaches it, with the questions the application asks about it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._menu = menu + self.properties = ProjectProperties(bridge, hand, menu) + self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED) + self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_SAVED) + self.replace_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_OPEN) + + def open(self) -> None: + """Chooses File ▸ Open project, which asks for a file.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT) + + def create(self) -> None: + """Starts a new project from File ▸ New project.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT) + + def close(self) -> None: + """Closes the project from File ▸ Close project.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_CLOSE_PROJECT) + + def save_as(self) -> None: + """Saves the project from File ▸ Save project as, which asks where.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS) diff --git a/tests/suite/screens/views/prompts.py b/tests/suite/screens/views/prompts.py new file mode 100644 index 000000000..25409a771 --- /dev/null +++ b/tests/suite/screens/views/prompts.py @@ -0,0 +1,87 @@ +from typing import List, Optional, Tuple + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON, SUF_BUTTON_CANCEL, SUF_BUTTON_OK, SUF_BUTTON_SAVE +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import WindowReading, read_windows +from tests.suite.screens.dearpygui.items.texts import read_shown_labels, read_shown_texts +from tests.suite.screens.dearpygui.items.types import BUTTON_TYPE + + +class MissingPromptError(AssertionError): + """Raised when a scenario answers a prompt that stands nowhere on the screen.""" + + +class Prompt: + """A question the application asks in a dialog of its own, found by the tag its kind composes under. + + Each prompt the application opens carries a fresh suffix on its kind's tag, so the prompt is the + shown window standing under that tag, and its buttons are composed from the window it stands in. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tag = tag + + def window(self) -> Optional[WindowReading]: + """The prompt's window while one is shown, and ``None`` otherwise.""" + shown = self.shown_windows() + return shown[0] if shown else None + + def shown_windows(self) -> List[WindowReading]: + """Every window of the prompt's kind standing on the screen, which is one for a question asked once.""" + return [ + window + for window in self._bridge.ask(read_windows) + if window.shown and is_tag_within(window.alias, self._tag) + ] + + def is_shown(self) -> bool: + """Whether a prompt of this kind stands on the screen.""" + return self.window() is not None + + def title(self) -> str: + """The title the prompt's window carries.""" + return self._standing().label + + def words(self) -> str: + """Everything the prompt says, its lines joined by spaces.""" + window = self._standing() + texts: Tuple[str, ...] = self._bridge.ask(lambda: read_shown_texts(window.alias)) + return " ".join(texts) + + def answers(self) -> Tuple[str, ...]: + """The buttons the prompt offers, read left to right.""" + window = self._standing() + return self._bridge.ask(lambda: read_shown_labels(window.alias, BUTTON_TYPE)) + + def confirm(self) -> None: + """Presses the button that says yes: Discard, Load, Exit and the like.""" + self._press(SUF_BUTTON_OK) + + def save(self) -> None: + """Presses Save on a prompt that offers to save first.""" + self._press(SUF_BUTTON_SAVE) + + def cancel(self) -> None: + """Presses the button that takes the question back.""" + self._press(SUF_BUTTON_CANCEL) + + def _press(self, suffix: str) -> None: + self._hand.click(compose_tag(self._standing().alias, suffix, SUF_BUTTON)) + + def _standing(self) -> WindowReading: + """The window of the prompt on the screen; raises ``MissingPromptError`` when none stands.""" + window = self.window() + if window is None: + raise MissingPromptError(f"No prompt under '{self._tag}' stands on the screen") + + return window diff --git a/tests/suite/screens/views/reconstructions.py b/tests/suite/screens/views/reconstructions.py new file mode 100644 index 000000000..4011394b6 --- /dev/null +++ b/tests/suite/screens/views/reconstructions.py @@ -0,0 +1,143 @@ +from pathlib import Path + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_TOOLTIP, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, +) +from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_CHANNEL, + TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION, + TAG_RECONSTRUCTIONS_BROWSER_TREE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import read_texts +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.instruments import Instruments +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.views.stems import StemsCard +from tests.suite.screens.views.waveform import Waveform + +OPEN_FILE_TOOLTIP = compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, SUF_TOOLTIP) + + +class Reconstructions: + """The Reconstructions tab: the browser of saved reconstructions, and the one open beside it. + + Every question about unsaved changes to the open reconstruction stands under one kind of + prompt, whichever gesture asked it, so ``unsaved_prompt`` reads any of them, and its title and + words tell which. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.browser = FileTree(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_TREE) + self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION) + self.replaced_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED) + self.remove_prompt = Prompt(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION) + self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED) + self.waveform = Waveform(bridge, hand, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM) + self.instruments = Instruments(bridge, hand) + self.stems = StemsCard(bridge, hand) + + def open_from_menu(self) -> None: + """Chooses Reconstruction ▸ Open, which asks for a file.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_OPEN) + + def close_from_menu(self) -> None: + """Chooses Reconstruction ▸ Close.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE) + + def save_from_menu(self) -> None: + """Chooses Reconstruction ▸ Save.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE) + + def can_close(self) -> bool: + """Whether Reconstruction ▸ Close answers.""" + return self._menu.is_enabled(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE) + + def can_save(self) -> bool: + """Whether Reconstruction ▸ Save answers.""" + return self._menu.is_enabled(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE) + + def open_file(self) -> str: + """The whole path of the open reconstruction's file, as the hover over its shortened path shows it. + + While nothing is open, or the reconstruction has no file yet, the hover shows the status of the line. + """ + return " ".join(self._bridge.ask(lambda: read_texts(OPEN_FILE_TOOLTIP))) + + def shows_open(self, path: Path) -> bool: + """Whether the reconstruction open is the one stored at ``path``.""" + return self.open_file() == str(path.absolute()) + + def file_line(self) -> str: + """What the Reconstruction file line of Source settings shows: a shortened path, or a status.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE))) + + def can_retune(self) -> bool: + """Whether the NES frequency field takes a new rate.""" + return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)).enabled + + def retune_lock_explained(self) -> bool: + """Whether hovering the NES frequency field shows why the rate is locked.""" + return self._bridge.ask( + lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED) + ).shown + + def retune_lock_words(self) -> str: + """What hovering the locked NES frequency field says.""" + return " ".join( + self._bridge.ask(lambda: read_texts(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)) + ) + + def audio_source(self) -> str: + """The source the switch above the waveform names: the reconstruction, or the original audio.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE))) + + def can_choose_audio_source(self) -> bool: + """Whether the switch above the waveform answers a press.""" + return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE)).enabled + + def choose_audio_source(self, label: str) -> None: + """Picks the source the switch names ``label``.""" + self._bridge.ask( + lambda: choose(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, label, CallbackQueue.run) + ) + + def channel_ticked(self, channel: ChannelName) -> bool: + """Whether the box of ``channel`` above the waveform stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) + + def channel_plays(self, channel: ChannelName) -> bool: + """Whether the box of ``channel`` answers, which it does for a channel the reconstruction plays.""" + return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled + + def tick_channel(self, channel: ChannelName) -> None: + """Clicks the box of ``channel`` above the waveform, which flips its tick.""" + self._hand.click(_channel_box(channel)) + + +def _channel_box(channel: ChannelName) -> str: + return compose_tag(PRE_RECONSTRUCTION_CHANNEL, channel.value) diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py new file mode 100644 index 000000000..8840e7d37 --- /dev/null +++ b/tests/suite/screens/views/sequencer.py @@ -0,0 +1,229 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import ( + TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, + TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, + TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP, +) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_BROWSER_TREE, + TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, + TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, + TAG_SEQUENCER_ORDER_TABLE, + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_DIALOG_REMOVE, + TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TABLE, + TAG_SEQUENCER_VOICES_WINDOW, +) +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import read_region_view, read_table +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.history import History +from tests.suite.screens.views.main import Card +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.views.tracker import Tracker + +NAME_COLUMN: Final[int] = 2 +KIND_COLUMN: Final[int] = 0 +COLOR: Final[str] = "color" +MASTER_ORDER_ROW: Final[int] = 0 +FIRST_CHANNEL_ORDER_ROW: Final[int] = 2 +ORDER_LABEL_CELLS: Final[int] = 1 +CELL_CONTENT: Final[int] = 0 +CLEAR_OF_THE_EDGE: Final[int] = 12 + + +class Voices: + """The Voices card of the Sequencer: one row per voice of the project, its number and its name.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.remove_prompt = Prompt(bridge, hand, TAG_SEQUENCER_VOICES_DIALOG_REMOVE) + self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) + self.imported_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED) + self.card = Card(bridge, hand, TAG_SEQUENCER_VOICES_PANEL) + + def kind_color(self, name: str) -> Tuple[float, ...]: + """The color of the mark naming the kind of the voice ``name``.""" + + def read() -> Tuple[float, ...]: + row = next( + row + for row in read_table(TAG_SEQUENCER_VOICES_TABLE) + if read_label(row[NAME_COLUMN][CELL_CONTENT]) == name + ) + return tuple(float(part) for part in dpg.get_item_configuration(row[KIND_COLUMN][CELL_CONTENT])[COLOR]) + + return self._bridge.ask(read) + + def pick(self, row: Item) -> None: + """Clicks ``row``, which picks its voice.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def is_picked(self, position: int) -> bool: + """Whether the row at ``position`` stands picked.""" + return bool(self._bridge.ask(lambda: dpg.is_table_row_highlighted(TAG_SEQUENCER_VOICES_TABLE, position))) + + def new_instrument(self) -> None: + """Clicks New instrument above the list.""" + self._hand.scroll_into_view(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) + self._hand.click(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) + + def names(self) -> List[str]: + """The names the rows show, top to bottom.""" + + def read() -> List[str]: + return [read_label(name) for name in _names()] + + return self._bridge.ask(read) + + def row(self, name: str) -> Optional[Item]: + """The name cell of the first row reading ``name``, if one is drawn.""" + return self._bridge.ask(lambda: next((item for item in _names() if read_label(item) == name), None)) + + def edit(self, row: Item) -> None: + """Double-clicks ``row``, which opens its voice on the Reconstructions tab.""" + self._hand.scroll_into_view(row) + self._hand.double_click(row) + + def right_click(self, row: Item) -> None: + """Clicks ``row`` with the right button, which opens its menu.""" + self._hand.scroll_into_view(row) + self._hand.right_click(row) + + def right_click_below_the_rows(self) -> None: + """Clicks the empty foot of the list with the right button, which opens the list's own menu.""" + view = self._bridge.ask(lambda: read_region_view(TAG_SEQUENCER_VOICES_WINDOW)) + if view is None: + raise LookupError("The list of voices stands nowhere") + + self._hand.right_click_at(Point(x=view.center.x, y=round(view.y + view.height) - CLEAR_OF_THE_EDGE)) + + +class Playback: + """Song playback as the Playback menu offers it: Play, which reads Pause while a song plays, and Stop.""" + + def __init__( + self, + bridge: Bridge, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + + def play(self) -> None: + """Chooses Playback ▸ Play.""" + self._menu.choose(MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY) + + def stop(self) -> None: + """Chooses Playback ▸ Stop.""" + self._menu.choose(MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_STOP) + + def play_entry(self) -> str: + """What the first entry of the Playback menu reads: Play while stopped, Pause while a song plays.""" + return self._bridge.ask(lambda: read_label(TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) + + def can_stop(self) -> bool: + """Whether Playback ▸ Stop answers, which it does while something plays.""" + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP)).enabled + + +class OrderTable: + """The order table: a row per channel under the Master row, a column per frame of the song.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def label(self, channel: Optional[ChannelName], position: int) -> str: + """What the entry of ``channel`` at frame ``position`` reads, the Master row's for ``None``.""" + return self._bridge.ask(lambda: read_label(_order_entry(channel, position))) + + def positions(self) -> int: + """How many frames the song orders.""" + return self._bridge.ask( + lambda: len(read_table(TAG_SEQUENCER_ORDER_TABLE)[MASTER_ORDER_ROW]) - ORDER_LABEL_CELLS + ) + + def click(self, channel: Optional[ChannelName], position: int) -> None: + """Clicks the entry of ``channel`` at frame ``position``, scrolling it into view first.""" + entry = self._bridge.ask(lambda: _order_entry(channel, position)) + self._hand.scroll_into_view(entry) + self._hand.click(entry) + + +class ModuleOptions: + """The module options card: the song's NES frequency, rows, tempo and speed.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.retune_prompt = Prompt(bridge, hand, TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY) + + def nes_frequency(self) -> int: + """The NES frequency the field holds.""" + return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY))) + + def retype_nes_frequency(self, frequency: int) -> None: + """Types ``frequency`` over the NES frequency field and presses Enter, which commits it.""" + self._hand.scroll_into_view(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY) + self._hand.replace_text(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, str(frequency)) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + +class Sequencer: + """The Sequencer tab: the tracker, the voices, the history, and song playback.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self.voices = Voices(bridge, hand) + self.playback = Playback(bridge, menu) + self.tracker = Tracker(bridge, hand) + self.history = History(bridge, hand) + self.browser = FileTree(bridge, hand, TAG_SEQUENCER_BROWSER_TREE) + self.order = OrderTable(bridge, hand) + self.module = ModuleOptions(bridge, hand) + + +def _names() -> List[Item]: + """The name cell of every voice row, top to bottom. Runs on the render thread.""" + return [row[NAME_COLUMN][CELL_CONTENT] for row in read_table(TAG_SEQUENCER_VOICES_TABLE) if len(row) > NAME_COLUMN] + + +def _order_entry(channel: Optional[ChannelName], position: int) -> Item: + """The entry of ``channel`` at frame ``position`` in the order table. Runs on the render thread.""" + row = MASTER_ORDER_ROW if channel is None else FIRST_CHANNEL_ORDER_ROW + ChannelName.items().index(channel) + entry: Item = read_table(TAG_SEQUENCER_ORDER_TABLE)[row][ORDER_LABEL_CELLS + position][CELL_CONTENT] + return entry diff --git a/tests/suite/screens/views/source.py b/tests/suite/screens/views/source.py new file mode 100644 index 000000000..60bab765e --- /dev/null +++ b/tests/suite/screens/views/source.py @@ -0,0 +1,44 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_CHANNELS, SUF_CHECKBOX +from sampletones_application.tags.main import PRE_MAIN_SOURCE_CHANNEL, TAG_MAIN_SOURCE_TEXT_SUBJECT +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.colors import read_theme + + +class SourceSettings: + """The Source settings card: what the picked row converts with, channel by channel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def subject(self) -> str: + """What the card names as the row it speaks for.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT))) + + def channel_box(self, channel: ChannelName) -> str: + """The tag of the checkbox that lets the row convert with ``channel``.""" + return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel, SUF_CHANNELS, SUF_CHECKBOX) + + def channel_ticked(self, channel: ChannelName) -> bool: + """Whether the checkbox of ``channel`` stands ticked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(channel)))) + + def channel_theme(self, channel: ChannelName) -> Optional[str]: + """The theme the checkbox of ``channel`` wears, or ``None`` while it wears none.""" + return self._bridge.ask(lambda: read_theme(self.channel_box(channel))) + + def tick(self, channel: ChannelName) -> None: + """Clicks the checkbox of ``channel``, scrolling it into view first.""" + self._hand.scroll_into_view(self.channel_box(channel)) + self._hand.click(self.channel_box(channel)) diff --git a/tests/suite/screens/views/stems.py b/tests/suite/screens/views/stems.py new file mode 100644 index 000000000..51947b5a8 --- /dev/null +++ b/tests/suite/screens/views/stems.py @@ -0,0 +1,86 @@ +from typing import Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_GROUP, SUF_SWATCH, SUF_TEXT +from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_STEMS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, +) +from sampletones_application.ui.elements.stems.tags import StemsTags +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.regions import enclosing_regions +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.views.prompts import Prompt + + +class StemsCard: + """The Stems card of the Reconstructions tab: a row per recording of the open reconstruction. + + A row is known by the key the card gives its recording, which is the recording's number in + the reconstruction. The card is the last of its column, so a gesture on a row first scrolls the + column to its end, as a person scrolls down to it. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_RECONSTRUCTION_STEMS) + self.remove_prompt = Prompt(bridge, hand, TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION) + + def has_row(self, key: str) -> bool: + """Whether a row for the recording ``key`` stands on the screen.""" + reading = self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_GROUP))) + return reading.exists and reading.shown + + def name(self, key: str) -> str: + """The name the row of ``key`` shows.""" + return self._bridge.ask(lambda: read_label(self.tags.row(key, SUF_TEXT))) + + def is_highlighted(self, key: str) -> bool: + """Whether the row's name stands highlighted, as a picked row does.""" + return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_TEXT)))) + + def swatch(self, key: str) -> Tuple[float, ...]: + """The color the row's swatch is filled with.""" + return tuple( + float(part) + for part in self._bridge.ask(lambda: dpg.get_item_configuration(self.tags.row(key, SUF_SWATCH))["fill"]) + ) + + def can_remove(self, key: str) -> bool: + """Whether the row's remove button answers.""" + return self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_BUTTON))).enabled + + def remove(self, key: str) -> None: + """Clicks the row's remove button, which asks first.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_BUTTON))) + + def click(self, key: str) -> None: + """Clicks the name of the row of ``key``, which picks it.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_TEXT))) + + def reveal(self, key: str) -> None: + """Double-clicks the row's name, which shows its recording in the file manager.""" + self._hand.double_click(self._in_view(self.tags.row(key, SUF_TEXT))) + + def hears(self, key: str) -> bool: + """Whether the row's box stands ticked, which is whether its recording is heard.""" + return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_CHECKBOX)))) + + def tick(self, key: str) -> None: + """Clicks the box of the row of ``key``, which flips whether its recording is heard.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_CHECKBOX))) + + def _in_view(self, item: str) -> str: + """Scrolls the column the card stands in to its end and ``item`` into view, and returns ``item``.""" + column = self._bridge.ask(lambda: enclosing_regions(self.tags.well))[-1] + self._hand.scroll_to_end(column) + self._hand.scroll_into_view(item) + return item diff --git a/tests/suite/screens/views/tabs.py b/tests/suite/screens/views/tabs.py new file mode 100644 index 000000000..8596db120 --- /dev/null +++ b/tests/suite/screens/views/tabs.py @@ -0,0 +1,42 @@ +from typing import Dict, Final + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.general import ( + TAG_GLOBAL_TAB_INSTRUCTIONS, + TAG_GLOBAL_TAB_MAIN, + TAG_GLOBAL_TAB_RECONSTRUCTION, + TAG_GLOBAL_TAB_SEQUENCER, + TAG_GLOBAL_TABS, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_selected_tab + +TAB_TAGS: Final[Dict[Tab, str]] = { + Tab.MAIN: TAG_GLOBAL_TAB_MAIN, + Tab.RECONSTRUCTIONS: TAG_GLOBAL_TAB_RECONSTRUCTION, + Tab.SEQUENCER: TAG_GLOBAL_TAB_SEQUENCER, + Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS, +} + +TABS_BY_TAG: Final[Dict[str, Tab]] = {tag: tab for tab, tag in TAB_TAGS.items()} + + +class Tabs: + """The tab bar across the window: which tab stands in front, and the headers that bring one forward.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def bring_to_front(self, tab: Tab) -> None: + """Clicks the header of ``tab``.""" + self._hand.click(TAB_TAGS[tab]) + + def front(self) -> Tab: + """The tab standing in front.""" + return TABS_BY_TAG[self._bridge.ask(lambda: read_selected_tab(TAG_GLOBAL_TABS))] diff --git a/tests/suite/screens/views/tracker.py b/tests/suite/screens/views/tracker.py new file mode 100644 index 000000000..bfe30dd2a --- /dev/null +++ b/tests/suite/screens/views/tracker.py @@ -0,0 +1,140 @@ +from typing import Final, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.sequencer import TAG_SEQUENCER_TRACKER_INPUT_OCTAVE, TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.panels.sequencer.columns import tracker_table_column, tracker_table_row +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.colors import read_theme_text_color +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT +from tests.suite.screens.dearpygui.reach import UnreachableError + +HEADER_ROW: Final[int] = 0 +CELL_GROUP: Final[int] = 0 +HALF: Final[float] = 0.5 + + +def tracker_cell(row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Item: + """The selectable drawing one subcolumn of one channel's cell on a pattern row. Runs on the render thread. + + The Sample column stands for ``channel`` ``None``. + """ + table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[tracker_table_row(row)] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + group = dpg.get_item_children(cell, 1)[CELL_GROUP] + slot: Item = dpg.get_item_children(group, 1)[SUBCOLUMNS.index(subcolumn)] + return slot + + +def tracker_cell_theme(row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + """The theme the cell's text is drawn with, which cells of one kind and state share. Runs on the render thread.""" + theme = dpg.get_item_info(tracker_cell(row, channel, subcolumn))["theme"] + return int(theme) if theme is not None else None + + +def _header_label(channel: Optional[ChannelName]) -> Item: + """The name heading ``channel``'s column, the Sample column's for ``None``. Runs on the render thread.""" + table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[HEADER_ROW] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + label: Item = dpg.get_item_children(cell, 1)[CELL_GROUP] + return label + + +class Tracker: + """The tracker grid: a row per pattern row, the Sample column and a column per channel, three slots to a cell. + + A cell's slots are the voice, the transpose and the volume, and a gesture names a slot by its + row, its channel, or ``None`` for the Sample column, and its subcolumn. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def header(self, channel: Optional[ChannelName]) -> str: + """What the header of ``channel``'s column reads, the Sample column's for ``None``.""" + return self._bridge.ask(lambda: read_label(_header_label(channel))) + + def click_header(self, channel: ChannelName) -> None: + """Clicks the name heading ``channel``'s column, which mutes the channel or lets it sound again.""" + label = self._bridge.ask(lambda: _header_label(channel)) + self._hand.click(label) + + def text_color(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[Tuple[float, ...]]: + """The color one slot's text is drawn in, as the theme the slot wears sets it.""" + + def read() -> Optional[Tuple[float, ...]]: + theme = tracker_cell_theme(row, channel, subcolumn) + return None if theme is None else read_theme_text_color(theme) + + return self._bridge.ask(read) + + def label(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> str: + """What one slot reads.""" + return self._bridge.ask(lambda: read_label(tracker_cell(row, channel, subcolumn))) + + def labels(self, row: int, channel: Optional[ChannelName]) -> Tuple[str, ...]: + """What the three slots of one cell read, voice first.""" + return tuple(self.label(row, channel, subcolumn) for subcolumn in SUBCOLUMNS) + + def theme(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + """The theme one slot's text wears, or ``None`` while it wears none.""" + return self._bridge.ask(lambda: tracker_cell_theme(row, channel, subcolumn)) + + def click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot, which puts the caret there.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click(item) + + def shift_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot holding Shift, which stretches the block from the caret to it.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click_holding(item, [IMGUI_LEFT_SHIFT]) + + def right_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot with the right button, which opens its menu.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.right_click(item) + + def has_caret(self, row: int, channel: Optional[ChannelName]) -> bool: + """Whether the caret stands in ``channel``'s cell on ``row``.""" + return bool( + self._bridge.ask( + lambda: dpg.is_table_cell_highlighted( + TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row), tracker_table_column(channel) + ) + ) + ) + + def is_row_tinted(self, row: int) -> bool: + """Whether the row carries a background, as a beat, a bar, the caret's row or the playing row does.""" + return bool( + self._bridge.ask(lambda: dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row))) + ) + + def octave(self) -> int: + """The octave the octave field holds.""" + return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE))) + + def raise_octave(self) -> None: + """Clicks the plus at the right end of the octave field, a square as tall as the field.""" + box = self._bridge.ask(lambda: read_item(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE).rect) + if box is None: + raise UnreachableError("The octave field reports no box") + + self._hand.click_at(Point(x=round(box.x + box.width - box.height * HALF), y=round(box.y + box.height * HALF))) diff --git a/tests/suite/screens/views/waveform.py b/tests/suite/screens/views/waveform.py new file mode 100644 index 000000000..26b660fd0 --- /dev/null +++ b/tests/suite/screens/views/waveform.py @@ -0,0 +1,140 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import ( + SUF_GRAPH_PLOT, + SUF_GRAPH_X_AXIS, + SUF_GRAPH_Y_AXIS, + SUF_WAVEFORM_OVERLAY, + SUF_WAVEFORM_POSITION_INDICATOR, +) +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge +from tests.suite.screens.dearpygui.geometry import Point, Rect +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.plots import settled_plot_pointer + +LINE_SERIES_TYPE: Final[str] = "mvAppItemType::mvLineSeries" +FIRST_POINT: Final[int] = 0 +Y_VALUES: Final[int] = 1 +MIDDLE: Final[float] = 0.5 +SETTLING_SECONDS: Final[float] = 10.0 + + +class MissingPlotError(AssertionError): + """Raised when a scenario reaches for a waveform the screen draws nowhere.""" + + +class Waveform: + """A waveform graph: where it stands, what it draws, and the cursor marking the sample that plays. + + Positions along the graph are read in samples, which is what the graph's x axis counts. A + gesture names a point by the fraction of the plot's width it lies at, halfway down the plot. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tag = tag + self.plot = compose_tag(tag, SUF_GRAPH_PLOT) + self.x_axis = compose_tag(tag, SUF_GRAPH_X_AXIS) + self.y_axis = compose_tag(tag, SUF_GRAPH_Y_AXIS) + self.cursor_line = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) + self.overlay = compose_tag(tag, SUF_WAVEFORM_OVERLAY) + + def point(self, fraction: float) -> Point: + """The viewport point ``fraction`` of the way across the plot, halfway down it.""" + box = self._box() + return Point(x=round(box.x + box.width * fraction), y=round(box.y + box.height * MIDDLE)) + + def sample_under(self, fraction: float) -> float: + """Rests the pointer ``fraction`` of the way across the plot and reads the sample the plot puts under it. + + Raises: + ExpectationError: If the plot never reports the pointer resting on it. + """ + self._hand.move_to(self.point(fraction)) + sample, _ = settled_plot_pointer(self._bridge, self.plot, timeout=SETTLING_SECONDS) + return sample + + def cursor(self) -> Optional[float]: + """The sample the cursor marks, or ``None`` while no cursor stands on the plot.""" + return self._bridge.ask(lambda: read_cursor(self.cursor_line)) + + def limits(self) -> Tuple[float, float]: + """The first and the last sample the plot shows.""" + low, high = self._bridge.ask(lambda: dpg.get_axis_limits(self.x_axis)) + return float(low), float(high) + + def series(self) -> Tuple[str, ...]: + """The tags of the lines the plot draws, in the order it draws them, the last on top.""" + + def read() -> Tuple[str, ...]: + return tuple( + str(dpg.get_item_alias(child)) + for child in dpg.get_item_children(self.y_axis, 1) + if dpg.get_item_info(child)["type"] == LINE_SERIES_TYPE + ) + + return self._bridge.ask(read) + + def series_tag(self, layer: str) -> str: + """The tag of the line the plot draws for ``layer``.""" + return compose_tag(self.y_axis, layer) + + def drawn(self, layer: str) -> List[float]: + """The heights the plot draws ``layer`` at, one per point along it.""" + return [float(value) for value in self._bridge.ask(lambda: dpg.get_value(self.series_tag(layer)))[Y_VALUES]] + + def zoom_in(self, fraction: float, notches: int) -> None: + """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, narrowing what it shows.""" + self._hand.wheel_at(self.point(fraction), -notches) + + def click(self, fraction: float) -> None: + """Clicks the plot ``fraction`` of the way across it.""" + self._hand.click_at(self.point(fraction)) + + def double_click(self, fraction: float) -> None: + """Double-clicks the plot ``fraction`` of the way across it.""" + self._hand.double_click_at(self.point(fraction)) + + def triple_click(self, fraction: float) -> None: + """Triple-clicks the plot ``fraction`` of the way across it.""" + self._hand.triple_click_at(self.point(fraction)) + + def drag(self, start: float, end: float) -> None: + """Drags across the plot from ``start`` to ``end``, each a fraction of its width.""" + self._hand.drag(self.point(start), self.point(end)) + + def _box(self) -> Rect: + """The plot's box once it stands where it stood a frame before, as a layout settling after a load leaves it.""" + settled = self._bridge.expect( + self._two_frames_of_box, + lambda boxes: boxes[0] is not None and boxes[0] == boxes[1], + description=f"the plot '{self.plot}' standing still", + timeout=SETTLING_SECONDS, + )[0] + if settled is None: + raise MissingPlotError(f"The plot '{self.plot}' reports no box") + + return settled + + def _two_frames_of_box(self) -> Tuple[Optional[Rect], Optional[Rect]]: + first = self._bridge.ask(lambda: read_item(self.plot).rect) + self._bridge.frames(ONE_FRAME) + return first, self._bridge.ask(lambda: read_item(self.plot).rect) + + +def read_cursor(cursor_line: str) -> Optional[float]: + """The sample ``cursor_line`` marks in this frame, or ``None`` while it stands hidden. Runs on the render thread.""" + if not read_item(cursor_line).shown: + return None + + return float(dpg.get_value(cursor_line)[FIRST_POINT][FIRST_POINT]) diff --git a/tests/suite/screens/vocabulary/__init__.py b/tests/suite/screens/vocabulary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/vocabulary/converter.py b/tests/suite/screens/vocabulary/converter.py new file mode 100644 index 000000000..7bf3ff04e --- /dev/null +++ b/tests/suite/screens/vocabulary/converter.py @@ -0,0 +1,9 @@ +from typing import Final + +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" +CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" +CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" +REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" +SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" diff --git a/tests/suite/screens/vocabulary/dialogs.py b/tests/suite/screens/vocabulary/dialogs.py new file mode 100644 index 000000000..f87b96900 --- /dev/null +++ b/tests/suite/screens/vocabulary/dialogs.py @@ -0,0 +1,14 @@ +from typing import Final + +SAVE: Final[str] = "global.dialog.label.save" +DISCARD: Final[str] = "global.dialog.label.discard" +CANCEL: Final[str] = "global.dialog.label.cancel" +CLOSE: Final[str] = "global.dialog.label.close" +LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" +LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" +CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" +CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" +EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" +EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" +EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" +EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" diff --git a/tests/suite/screens/vocabulary/exports.py b/tests/suite/screens/vocabulary/exports.py new file mode 100644 index 000000000..5f5858c25 --- /dev/null +++ b/tests/suite/screens/vocabulary/exports.py @@ -0,0 +1,5 @@ +from typing import Final + +EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" +NSF_TYPE: Final[str] = "global.dialog.filter.nsf" +NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" diff --git a/tests/suite/screens/vocabulary/instruments.py b/tests/suite/screens/vocabulary/instruments.py new file mode 100644 index 000000000..e6e1c52db --- /dev/null +++ b/tests/suite/screens/vocabulary/instruments.py @@ -0,0 +1,3 @@ +from typing import Final + +TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" diff --git a/tests/suite/screens/vocabulary/libraries.py b/tests/suite/screens/vocabulary/libraries.py new file mode 100644 index 000000000..371b877ff --- /dev/null +++ b/tests/suite/screens/vocabulary/libraries.py @@ -0,0 +1,3 @@ +from typing import Final + +LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" diff --git a/tests/suite/screens/vocabulary/playback.py b/tests/suite/screens/vocabulary/playback.py new file mode 100644 index 000000000..89e6f9c04 --- /dev/null +++ b/tests/suite/screens/vocabulary/playback.py @@ -0,0 +1,4 @@ +from typing import Final + +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +PLAY: Final[str] = "global.menu.label.item_playback_play" diff --git a/tests/suite/screens/vocabulary/recordings.py b/tests/suite/screens/vocabulary/recordings.py new file mode 100644 index 000000000..db28f5648 --- /dev/null +++ b/tests/suite/screens/vocabulary/recordings.py @@ -0,0 +1,4 @@ +from typing import Final + +KICK: Final[str] = "kick.wav" +SNARE: Final[str] = "snare.wav" diff --git a/tests/suite/screens/vocabulary/settings.py b/tests/suite/screens/vocabulary/settings.py new file mode 100644 index 000000000..2594f5111 --- /dev/null +++ b/tests/suite/screens/vocabulary/settings.py @@ -0,0 +1,3 @@ +from typing import Final + +LISTENING: Final[str] = "settings.keybindings.message.capturing" diff --git a/tests/suite/screens/worlds/__init__.py b/tests/suite/screens/worlds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/worlds/home.py b/tests/suite/screens/worlds/home.py new file mode 100644 index 000000000..06da69ce5 --- /dev/null +++ b/tests/suite/screens/worlds/home.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import Optional, Protocol, Tuple + +from sampletones_application.config.managers.application import ApplicationConfigManager +from sampletones_application.config.managers.state import ApplicationStateManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH +from tests.suite.screens.environment import SCREEN_SIZE + + +class HomeFile(Protocol): + """Something a scenario's home holds before the application starts: a recording, a document, a library.""" + + def write(self) -> None: + """Writes it under the home of the scenario's process.""" + + +@dataclass(frozen=True) +class World: + """What a scenario's home holds before the application draws its first frame. + + Each file is written by the same model and the same manager the application reads it with, so + a home seeded here is a home a previous run could have left. + + Attributes: + state: The session the application restores, or ``None`` for a home no run has left one in. + application_config: The application's settings, or ``None`` for a home holding none. + config: The reconstruction settings in the documents folder, or ``None`` for a home holding none. + files: Everything else the home holds. + """ + + state: Optional[ApplicationState] + application_config: Optional[ApplicationConfig] + config: Optional[Config] + files: Tuple[HomeFile, ...] + + def write(self, profile: UserProfile) -> None: + """Lays the world out in the home ``profile`` reads its settings from.""" + if self.state is not None: + state_manager = ApplicationStateManager(profile.state) + state_manager.state = self.state + state_manager.save() + + if self.application_config is not None: + config_manager = ApplicationConfigManager(profile.config) + config_manager.config = self.application_config + config_manager.save() + + if self.config is not None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + self.config.save(CONFIG_PATH) + + for file in self.files: + file.write() + + +def screen_filling_state() -> ApplicationState: + """A session whose window spans the scenario's screen, which the application fits within its margins.""" + return ApplicationState( + viewport=ViewportState( + width=SCREEN_SIZE.width, + height=SCREEN_SIZE.height, + x=0, + y=0, + ) + ) + + +def one_worker_config() -> Config: + """Reconstruction settings running one job at a time, which keeps a scenario's run on a worker of its + own. + """ + config = Config() + general = config.general.model_copy(update={"max_workers": 1}) + return config.model_copy(update={"general": general}) diff --git a/tests/suite/screens/worlds/recordings.py b/tests/suite/screens/worlds/recordings.py new file mode 100644 index 000000000..d48e3961d --- /dev/null +++ b/tests/suite/screens/worlds/recordings.py @@ -0,0 +1,109 @@ +from pathlib import Path +from typing import Final, Tuple + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.seeds.libraries import MiniLibrary +from tests.suite.screens.seeds.projects import StoredProject +from tests.suite.screens.seeds.reconstructions import PlayableReconstruction, StoredReconstruction +from tests.suite.screens.seeds.recordings import Recording, stored_recording +from tests.suite.screens.worlds.home import HomeFile, World, one_worker_config, screen_filling_state + +OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" +OTHER_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Other.stn" +SONG: Final[Path] = PROJECTS_DIRECTORY / "Song.stp" +SONG_SAMPLE: Final[str] = "Lead" +SONG_INSTRUMENT: Final[str] = "Pad" +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DOCUMENT_RECORDING_SECONDS: Final[float] = 0.3 +DOCUMENT_RECORDING_FREQUENCY: Final[float] = 220.0 +PLAYABLE_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Playable.stn" +SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn" +SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn" +TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") +STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") +STEM_FRAMES: Final[int] = 60 +PLAYABLE_FRAMES: Final[int] = 600 +FRAMES_PER_SECOND: Final[int] = 60 +TAKE_FREQUENCY: Final[float] = 220.0 + + +def converting_world(files: Tuple[HomeFile, ...]) -> World: + """A home ready to convert: one-worker settings, a small library built for them, and ``files``.""" + config = one_worker_config() + return World( + state=screen_filling_state(), + application_config=None, + config=config, + files=(MiniLibrary(config), *files), + ) + + +def lived_in_world() -> World: + """The home of a user who ran the application once: a session left behind, and nothing else.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=(), + ) + + +def documents_world() -> World: + """A home ready to convert that holds the documents a user puts away and replaces. + + It holds two reconstructions, :data:`OPEN_RECONSTRUCTION` and :data:`OTHER_RECONSTRUCTION`, + with the recording they name; the project :data:`SONG` with a sample and an instrument; and + two recordings to convert, :data:`BASS` and :data:`LEAD`, in the home itself. + """ + recordings = tuple( + Recording( + destination=Path.cwd() / name, + seconds=DOCUMENT_RECORDING_SECONDS, + frequency=DOCUMENT_RECORDING_FREQUENCY, + ) + for name in (BASS, LEAD) + ) + return converting_world( + ( + *recordings, + StoredReconstruction(OPEN_RECONSTRUCTION), + StoredReconstruction(OTHER_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ) + ) + + +def playing_world() -> World: + """A home holding reconstructions that play: two of one recording each, and one of three recordings in + turn. + + :data:`PLAYABLE_RECONSTRUCTION` and :data:`SECOND_PLAYABLE` play the first and the second of + :data:`TAKES`, the shorter :data:`STEMS_RECONSTRUCTION` plays the three :data:`STEM_TAKES` in + turn, and :data:`SHORT_RECONSTRUCTION` plays the first of them alone. The home holds the stored + :data:`OPEN_RECONSTRUCTION`, sounding every channel, and the project :data:`SONG` besides. + """ + takes = tuple(Path.cwd() / name for name in TAKES) + stem_takes = tuple(Path.cwd() / name for name in STEM_TAKES) + recordings = tuple( + Recording(destination=take, seconds=frames / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY * (index + 1)) + for paths, frames in ((takes, PLAYABLE_FRAMES), (stem_takes, STEM_FRAMES)) + for index, take in enumerate(paths) + ) + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=( + *recordings, + PlayableReconstruction(PLAYABLE_RECONSTRUCTION, takes[:1], PLAYABLE_FRAMES), + PlayableReconstruction(SECOND_PLAYABLE, takes[1:2], PLAYABLE_FRAMES), + PlayableReconstruction(STEMS_RECONSTRUCTION, stem_takes, STEM_FRAMES), + PlayableReconstruction(SHORT_RECONSTRUCTION, stem_takes[:1], STEM_FRAMES), + StoredReconstruction(OPEN_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ), + ) diff --git a/tests/suite/screens/worlds/songs.py b/tests/suite/screens/worlds/songs.py new file mode 100644 index 000000000..98b2e2566 --- /dev/null +++ b/tests/suite/screens/worlds/songs.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.seeds.projects import ( + ArrangedProject, + LongEnvelopeProject, + OverlongProject, + ReleasingInstrumentFile, + TwoTuningsProject, +) +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import playing_world + +ARRANGED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Arranged.stp" +LINE: Final[str] = "Line" +BASS_VOICE: Final[str] = "Bass" +PAD: Final[str] = "Pad" +PAD_ROW: Final[int] = 4 +BASS_ROW: Final[int] = 8 +SINGLE_ORDER_FRAME: Final[int] = 1 +LOOPING_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Looping.stp" +LOOPING_ORDER_FRAMES: Final[int] = 2 +OVERLONG_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Overlong.stp" +OVERLONG_SAMPLE: Final[str] = "Turning" +OVERLONG_ORDER_FRAMES: Final[int] = 12 +TWO_TUNINGS_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Tunings.stp" +RETUNED_A4: Final[float] = 432.0 +LONG_ENVELOPES_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Envelopes.stp" +LONG_VOICE: Final[str] = "Long" +LONG_ITEMS: Final[int] = 600 +MIDDLING_VOICE: Final[str] = "Middling" +MIDDLING_ITEMS: Final[int] = 300 +INSTRUMENTS_FOLDER: Final[str] = "instruments" +RELEASING_INSTRUMENT: Final[str] = "Releasing" + + +def sequencer_world() -> World: + """A home holding :data:`ARRANGED_PROJECT`, its voices placed on its first pattern, and the playing + world's files. + + The project's sample :data:`LINE` starts the pattern on Pulse 1, its hand-written :data:`PAD` + comes in on Pulse 2 at :data:`PAD_ROW`, and its sample :data:`BASS_VOICE` on the triangle at + :data:`BASS_ROW`. + """ + playing = playing_world() + return World( + state=playing.state, + application_config=None, + config=None, + files=( + *playing.files, + arranged_project(ARRANGED_PROJECT, SINGLE_ORDER_FRAME), + ), + ) + + +def exporting_world() -> World: + """The sequencer's home, holding besides the projects an export meets at its limits. + + :data:`LOOPING_PROJECT` is :data:`ARRANGED_PROJECT` with its pattern played twice in the order. + :data:`OVERLONG_PROJECT` changes every channel at every tick through :data:`OVERLONG_ORDER_FRAMES` + frames. :data:`TWO_TUNINGS_PROJECT` holds :data:`LINE` at the default tuning and + :data:`BASS_VOICE` with A4 at :data:`RETUNED_A4` hertz. :data:`LONG_ENVELOPES_PROJECT` holds + :data:`LONG_VOICE` and :data:`MIDDLING_VOICE`, whose volume envelopes run :data:`LONG_ITEMS` and + :data:`MIDDLING_ITEMS` items. The home's :data:`INSTRUMENTS_FOLDER` holds the FamiTracker instrument + :data:`RELEASING_INSTRUMENT`, whose volume states a release point. + """ + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=None, + config=None, + files=( + *sequencer.files, + arranged_project(LOOPING_PROJECT, LOOPING_ORDER_FRAMES), + OverlongProject(OVERLONG_PROJECT, sample=OVERLONG_SAMPLE, order_frames=OVERLONG_ORDER_FRAMES), + TwoTuningsProject(TWO_TUNINGS_PROJECT, line=LINE, bass=BASS_VOICE, a4_frequency=RETUNED_A4), + LongEnvelopeProject( + LONG_ENVELOPES_PROJECT, + long=LONG_VOICE, + long_items=LONG_ITEMS, + middling=MIDDLING_VOICE, + middling_items=MIDDLING_ITEMS, + ), + ReleasingInstrumentFile(releasing_instrument(), name=RELEASING_INSTRUMENT), + ), + ) + + +def releasing_instrument() -> Path: + """Where the home holds the FamiTracker instrument :data:`RELEASING_INSTRUMENT`.""" + return Path.cwd() / INSTRUMENTS_FOLDER / f"{RELEASING_INSTRUMENT}{EXT_FILE_INSTRUMENT}" + + +def arranged_project(destination: Path, order_frames: int) -> ArrangedProject: + """The arranged project laid at ``destination``, its pattern played ``order_frames`` times in the order.""" + return ArrangedProject( + destination, + line=LINE, + bass=BASS_VOICE, + pad=PAD, + pad_row=PAD_ROW, + bass_row=BASS_ROW, + order_frames=order_frames, + ) diff --git a/tests/suite/screens/written.py b/tests/suite/screens/written.py new file mode 100644 index 000000000..0cd25bd8c --- /dev/null +++ b/tests/suite/screens/written.py @@ -0,0 +1,25 @@ +from sampletones_application.config.managers.application import ApplicationConfigManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.state import ApplicationStateManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH + + +def written_state() -> ApplicationState: + """The session the application left in its home, read by the manager that restores it at the next start.""" + return ApplicationStateManager(UserProfile.user().state).state + + +def written_application_config() -> ApplicationConfig: + """The application settings left in the home, read by the manager the next start reads them with.""" + return ApplicationConfigManager(UserProfile.user().config).config + + +def written_config() -> Config: + """The reconstruction settings left in the documents folder, read by the manager the next start reads + them with. + """ + return ConfigManager(CONFIG_PATH).config diff --git a/tests/suite/status.py b/tests/suite/status.py new file mode 100644 index 000000000..e9b1bb5cc --- /dev/null +++ b/tests/suite/status.py @@ -0,0 +1,22 @@ +from typing import Any, List, Union + +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_shared.types.callback import MessageCallback + + +class RecordedStatusBar(GUIStatusBar): + """A status bar keeping every message it is given, with no window or theme to draw them with. + + A test reading what a hover or a gesture says reads the messages, in the order they were set. + """ + + def __init__(self) -> None: # pylint: disable=super-init-not-called + self.messages: List[str] = [] + + def set( + self, + message_or_function: Union[str, MessageCallback], + *args: Any, + **kwargs: Any, + ) -> None: + self.messages.append(self.get_message(message_or_function, *args, **kwargs)) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py index 6b9d9e893..bd0982316 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py @@ -1,8 +1,21 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg import pytest from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.structures.tree import NodeType, TreeNode from tests.suite.language import FakeLanguageManager +from tests.suite.status import RecordedStatusBar + + +@pytest.fixture +def context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() def _panel() -> GUISequencerBrowserPanel: @@ -29,3 +42,20 @@ def test_the_message_names_what_the_row_holds( panel = _panel() assert panel._expandable_node_message(TreeNode("row", node_type=node_type)) == key + + +@pytest.mark.usefixtures("context") +class TestAHoverReportedAfterTheRowWent: + def test_a_row_a_rebuild_removed_leaves_the_status_bar_as_it_was(self) -> None: + """The hover arrives a frame late, and a row the rebuilt tree no longer holds says nothing.""" + status_bar = RecordedStatusBar() + panel = _panel() + panel._status_bar = status_bar + hovered = panel._create_hover_callback(lambda *_args, **_kwargs: "row") + with dpg.window(): + row = dpg.add_text("row") + dpg.delete_item(row) + + hovered(0, row) + + assert status_bar.messages == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py index ee54015ba..6addca213 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py @@ -1,5 +1,6 @@ -from typing import Final, Optional, Tuple +from typing import Final, Iterator, Optional, Tuple +import dearpygui.dearpygui as dpg import pytest from sampletones_application.categories.manager import LanguageManager @@ -17,6 +18,7 @@ from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint +from tests.suite.status import RecordedStatusBar SAMPLE_ID: Final[str] = "kick-id" INSTRUMENT_ID: Final[str] = "lead-id" @@ -38,6 +40,15 @@ INSTRUMENT_FOOTPRINT: Final[VoiceFootprintViewModel] = VoiceFootprintViewModel.from_instrument(PULSE_1_FOOTPRINT) +@pytest.fixture +def context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + def _panel(footprint: Optional[VoiceFootprintViewModel]) -> GUISequencerVoicesPanel: """The panel over the facts a hovered row reads, with no DearPyGui context behind it.""" language_manager = LanguageManager(LANG_EN) @@ -92,3 +103,30 @@ def test_a_voice_with_nothing_to_state_says_nothing( footprint: Optional[VoiceFootprintViewModel], ) -> None: assert _panel(footprint)._voice_status_message(voice_id) == "" + + +@pytest.mark.usefixtures("context") +class TestAHoverReportedAfterTheRowWent: + def test_a_standing_cell_names_its_voice(self) -> None: + status_bar = RecordedStatusBar() + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = status_bar + with dpg.window(): + cell = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + + panel._on_row_hovered(0, cell) + + assert len(status_bar.messages) == 1 + assert status_bar.messages[0].startswith("Kick") + + def test_a_cell_a_rebuild_removed_leaves_the_status_bar_as_it_was(self) -> None: + status_bar = RecordedStatusBar() + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = status_bar + with dpg.window(): + cell = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + dpg.delete_item(cell) + + panel._on_row_hovered(0, cell) + + assert status_bar.messages == [] diff --git a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py index 31bdd1ed1..5d5b976fc 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py @@ -110,6 +110,14 @@ def test_zero_delay_callback_is_due_at_current_frame(self) -> None: assert fired == [True] + def test_current_frame_counts_each_frame_drawn(self) -> None: + start = CallbackQueue.current_frame() + + CallbackQueue.notify_frame() + CallbackQueue.notify_frame() + + assert CallbackQueue.current_frame() == start + 2 + class TestCallbackQueueBudget: def test_zero_budget_runs_one_due_callback_per_call(self) -> None: diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py index 71eb277cb..417cdc248 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -95,6 +95,29 @@ def test_an_action_the_scheme_leaves_unassigned_answers_no_press(self, source: S callback.assert_not_called() +class TestShortcutLookup: + """A reader outside the dispatch asks the manager which keys an action answers to.""" + + def test_the_binding_read_is_the_one_the_scheme_gives(self, source: ShortcutSource) -> None: + manager = _manager(source, ShortcutId.SAVE_PROJECT, Mock()) + + shortcut = manager.shortcut(ShortcutId.SAVE_PROJECT) + + assert shortcut == source.shortcut(ShortcutId.SAVE_PROJECT) + + def test_the_binding_read_follows_a_rebind( + self, + source: ShortcutSource, + rebound: RebindScheme, + ) -> None: + manager = _manager(source, ShortcutId.SAVE_PROJECT, Mock()) + + source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")})) + manager.rebind() + + assert manager.shortcut(ShortcutId.SAVE_PROJECT).matches(_event(dpg.mvKey_K, modifiers=CTRL_ALT)) + + class TestRebind: """A registration names the action, so activating another scheme changes the keys that fire it.""" diff --git a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py index 62be65bed..61ecd9d8e 100644 --- a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -254,3 +254,59 @@ def answer() -> None: held_frames.render() assert screen.built == [DIALOG, PROMPT, SECOND] + + +class TestTheSnapshot: + """A reader outside the line sees which window stands, which wait, and whether the line moves.""" + + def test_a_free_screen_reads_settled(self) -> None: + snapshot = ModalQueue.snapshot() + + assert snapshot.shown is None + assert snapshot.is_settled + + def test_the_standing_window_and_the_line_read_in_order(self, screen: Screen) -> None: + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + snapshot = ModalQueue.snapshot() + + assert snapshot.shown == FIRST + assert snapshot.waiting == (SECOND, THIRD) + assert not snapshot.is_settled + + @pytest.mark.usefixtures("held_frames") + def test_a_window_that_left_reads_as_a_turn_due(self, screen: Screen) -> None: + screen.open(FIRST) + + ModalQueue.leave(FIRST) + + snapshot = ModalQueue.snapshot() + assert snapshot.shown is None + assert snapshot.turning + assert not snapshot.is_settled + + def test_the_line_settles_a_frame_after_the_last_window_left( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + ModalQueue.leave(FIRST) + + held_frames.render() + + assert ModalQueue.snapshot().is_settled + + def test_a_dialog_standing_aside_reads_aside(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + snapshot = ModalQueue.snapshot() + + assert snapshot.shown == PROMPT + assert snapshot.aside == (DIALOG,) + assert not snapshot.is_settled diff --git a/tests/unit/scripts/test_run_tests.py b/tests/unit/scripts/test_run_tests.py index 35d9fbd73..19bcea748 100644 --- a/tests/unit/scripts/test_run_tests.py +++ b/tests/unit/scripts/test_run_tests.py @@ -3,7 +3,7 @@ import pytest -from bootstrap.layout import BENCHMARKS_DIRECTORY +from bootstrap.layout import BENCHMARKS_DIRECTORY, SCREENS_DIRECTORY from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.scripts import load_script @@ -13,24 +13,40 @@ class TestPlannedPasses: def test_every_pass_is_found_under_its_name(self) -> None: - passes = run_tests.planned_passes(run_tests.DEFAULT_WORKERS) + passes = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS) assert all(name == current.name for name, current in passes.items()) - assert set(passes) == {run_tests.SUITE, run_tests.DOCTESTS, run_tests.BENCHMARKS} + assert set(passes) == {run_tests.SUITE, run_tests.DOCTESTS, run_tests.BENCHMARKS, run_tests.SCREENS} - def test_the_suite_is_covered_across_the_workers_and_leaves_the_benchmarks_out(self) -> None: - command = run_tests.planned_passes("auto")[run_tests.SUITE].command + def test_the_suite_is_covered_across_the_workers_and_leaves_the_other_passes_out(self) -> None: + command = run_tests.planned_passes("auto", run_tests.SCREEN_WORKERS)[run_tests.SUITE].command - assert command[-4:] == ("-n", "auto", "--cov", f"--ignore={BENCHMARKS_DIRECTORY}") + assert command[-5:] == ( + "-n", + "auto", + "--cov", + f"--ignore={BENCHMARKS_DIRECTORY}", + f"--ignore={SCREENS_DIRECTORY}", + ) + + def test_the_screen_scenarios_run_across_their_own_workers_uncovered(self) -> None: + command = run_tests.planned_passes("auto", "2")[run_tests.SCREENS].command + + assert SCREENS_DIRECTORY in command + assert command[-3:] == ("-n", "2", "--no-cov") def test_the_doctests_read_the_sources_uncovered(self) -> None: - command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS)[run_tests.DOCTESTS].command + command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS)[ + run_tests.DOCTESTS + ].command assert "--doctest-modules" in command assert "--no-cov" in command def test_the_benchmarks_run_serial_uncovered_and_show_their_readings(self) -> None: - command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS)[run_tests.BENCHMARKS].command + command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS)[ + run_tests.BENCHMARKS + ].command assert BENCHMARKS_DIRECTORY in command assert "--no-cov" in command @@ -49,7 +65,7 @@ class TestCase(BaseRegularTestCase): ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) - def test_a_pass_outside_the_three_is_refused(self, test_case: TestCase) -> None: + def test_a_pass_outside_the_planned_ones_is_refused(self, test_case: TestCase) -> None: with pytest.raises(SystemExit) as exit_info: run_tests.main(test_case.argv) diff --git a/uv.lock b/uv.lock index f82d19b6c..af1c18c49 100644 --- a/uv.lock +++ b/uv.lock @@ -1664,6 +1664,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -1693,6 +1705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "pyvirtualdisplay" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/9f/23e5a82987c26d225139948a224a93318d7a7c8b166d4dbe4de7426dc4e4/PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159", size = 18560, upload-time = "2022-02-13T07:57:05.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/eb/c3b8deb661cb3846db63288c99bbb39f217b7807fc8acb2fd058db41e2e6/PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e", size = 15258, upload-time = "2022-02-13T07:57:04.051Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -1852,6 +1873,9 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "python-xlib" }, + { name = "pyvirtualdisplay" }, + { name = "types-python-xlib" }, { name = "types-pyyaml" }, ] @@ -1898,6 +1922,9 @@ dev = [ { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "python-xlib", specifier = "==0.33" }, + { name = "pyvirtualdisplay", specifier = "==3.0" }, + { name = "types-python-xlib", specifier = "==0.33.0.20260724" }, { name = "types-pyyaml", specifier = "==6.0.12.20260518" }, ] @@ -2018,6 +2045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "soundfile" version = "0.13.1" @@ -2122,6 +2158,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "types-python-xlib" +version = "0.33.0.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a3/5edce1508c62748b37d103e682abe4933184998a302a45e6e17e4a9510d1/types_python_xlib-0.33.0.20260724.tar.gz", hash = "sha256:81999c1d390f8fde0113286c0c45441c37fd404c03aa67c06997ab929dbab177", size = 54664, upload-time = "2026-07-24T04:59:01.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/a4/635ae88bae98d313c44adb8c36c970a3c19094541cb8b5c65f5d9d37cd57/types_python_xlib-0.33.0.20260724-py3-none-any.whl", hash = "sha256:1d47c3d7c783c5890ad255054c8a37aa766a6d9b7e3c12f1841ea3b1721375d7", size = 67523, upload-time = "2026-07-24T04:59:00.43Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518"