diff --git a/.github/probe/JideResolutionProbe.java b/.github/probe/JideResolutionProbe.java new file mode 100644 index 0000000..201a951 --- /dev/null +++ b/.github/probe/JideResolutionProbe.java @@ -0,0 +1,33 @@ +/** + * Runs the look-and-feel resolution that jide-oss performs inside every JIDE component, against + * a built jar, and exits non-zero if it fails. + * + * LookAndFeelFactory decides which style to install by asking + * "lnf instanceof com.sun.java.swing.plaf.windows.WindowsLookAndFeel". An instanceof must + * resolve its class before it can answer false, so an absent or inaccessible class is a link + * error thrown out of a constructor rather than a quiet no. The application reaches this + * whenever someone types into a combo box in a sheet: EditorComboBox installs a + * ComboBoxSearchable, its search popup is a JidePopup, and JidePopup.updateUI() - which runs + * from the JComponent constructor - calls installJideExtension(). + * + * The look and feel is set to FlatLaf first because that is the one the application installs, + * and because it is the case that matters: JIDE recognises Metal and Aqua and answers from an + * earlier branch, never reaching the instanceof. A probe left on the default look and feel + * passes whether or not the class is present, which is no check at all. + */ +public class JideResolutionProbe +{ + public static void main(String[] args) + { + System.setProperty("java.awt.headless", "true"); + try { + javax.swing.UIManager.setLookAndFeel("com.formdev.flatlaf.FlatDarculaLaf"); + com.jidesoft.plaf.LookAndFeelFactory.installJideExtension(); + } + catch (Throwable t) { + System.out.println("FAIL: " + t.getClass().getName() + ": " + t.getMessage()); + System.exit(1); + } + System.out.println("OK: WindowsLookAndFeel resolved and the JIDE extension installed"); + } +} diff --git a/.github/probe/manifest_value.py b/.github/probe/manifest_value.py new file mode 100644 index 0000000..53ed84d --- /dev/null +++ b/.github/probe/manifest_value.py @@ -0,0 +1,29 @@ +"""Reads one main-section attribute from a jar manifest on stdin. + +A manifest is wrapped to 72 bytes and continued with a leading single space, and the break can +fall in the middle of a token: the Add-Exports value this repository ships spans three lines and +currently splits java.desktop/sun.awt.shell across two of them. Which tokens get split moves +whenever the value changes, so reading the raw file is unreliable in both directions -- a package +that is present can be unfindable, and splitting one yields a garbage token rather than an +obvious error. Unfolding first is the only way to read the value as the JVM does. + +Only the main section is considered: per-entry sections follow the first blank line and may +repeat attribute names. +""" +import sys + +name = sys.argv[1] +text = sys.stdin.buffer.read().decode("utf-8", "replace").replace("\r\n", "\n") +main_section = text.split("\n\n", 1)[0] + +unfolded = [] +for line in main_section.split("\n"): + if line.startswith(" ") and unfolded: + unfolded[-1] += line[1:] + else: + unfolded.append(line) + +for line in unfolded: + if line.startswith(name + ":"): + print(line.split(":", 1)[1].strip()) + break diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e86be26 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,200 @@ +name: Build + +on: + push: + branches: ['**'] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + # This module compiles at source/target 20. + java-version: '21' + cache: maven + + # None of the three sibling libraries are on Maven Central at the versions this module + # depends on, so each is built from source. A branch of the same name wins when one + # exists, because a change spanning several of these repositories is developed on + # matching branches; otherwise main. Order matters -- Nds4j underpins the other two. + - name: Build upstream libraries + run: | + set -euo pipefail + ref="${{ github.head_ref || github.ref_name }}" + for repo in Nds4j Nds4j-ToolUI PokEditor-Core; do + target="$ref" + if ! git ls-remote --exit-code --heads "https://github.com/turtleisaac/$repo.git" "$ref" >/dev/null 2>&1; then + target=main + fi + echo "::group::$repo @ $target" + git clone --quiet --depth 1 --branch "$target" \ + "https://github.com/turtleisaac/$repo.git" ".upstream/$repo" + mvn -B -ntp -q install -DskipTests -f ".upstream/$repo/pom.xml" + echo "::endgroup::" + done + + # Attributed up front, so an unresolvable dependency is reported as itself rather than + # surfacing later as an unexplained compilation failure. + - name: Verify dependencies resolve + run: | + set +e + output=$(mvn -B -ntp dependency:resolve 2>&1) + status=$? + set -e + if [ "$status" -ne 0 ]; then + echo "$output" | tail -40 + if echo "$output" | grep -q "VariableTracker"; then + echo "::error title=Unpublished dependency blocks this build::io.github.turtleisaac:VariableTracker:1.0-SNAPSHOT is not published to any repository this build can reach, and pom.xml declares no entry for it. PokEditor therefore cannot be built from a clean checkout -- not by CI, and not by a new contributor. Fix by publishing VariableTracker, adding a entry that serves it, or vendoring it into this repository." + fi + exit "$status" + fi + + - name: Build and test + # Everything except the dead-code specifications below. This job must stay green: a + # failure here is a regression, and it is only readable as one because the known + # failures are not mixed in with it. + run: mvn -B -ntp verify -Djava.awt.headless=true -DexcludedGroups=dead-code + + # Tests asserting properties that framework/ and gui_old/ classes do not hold. Those + # classes have no callers anywhere in src/main, so the failures are not defects anyone + # can hit - they are the specification for whoever revives or deletes the code. + # + # The count is asserted rather than reported. A bare "expected to fail" job is decorative: + # nothing notices when the number goes up, so a genuine regression could be silenced by + # tagging it, and nothing notices when it goes down either, so a fix never prompts anyone + # to remove the tag. Pinning the number makes both show up as a build failure. + - name: Known dead-code failures + run: | + set +e + output=$(mvn -B -ntp test -Djava.awt.headless=true -Dgroups=dead-code 2>&1) + set -e + summary=$(echo "$output" | grep -E '^\[(ERROR|INFO|WARNING)\] Tests run: [0-9]+, Failures' | tail -1) + echo "$summary" + run=$(echo "$summary" | sed -E 's/.*Tests run: ([0-9]+).*/\1/') + failures=$(echo "$summary" | sed -E 's/.*Failures: ([0-9]+).*/\1/') + errors=$(echo "$summary" | sed -E 's/.*Errors: ([0-9]+).*/\1/') + red=$(( failures + errors )) + if [ "$run" != "$EXPECTED_TAGGED" ]; then + echo "::error title=Tagged test count changed::$run tests carry the dead-code tag, expected $EXPECTED_TAGGED. If you added a tag, say why in the commit and update EXPECTED_TAGGED; a regression must not be tagged away." + exit 1 + fi + if [ "$red" -lt "$EXPECTED_RED" ]; then + echo "::notice title=A known failure now passes::$red of $run tagged tests fail, down from $EXPECTED_RED. Something was fixed -- remove its @Tag(DEAD_CODE) and lower EXPECTED_RED." + exit 1 + fi + if [ "$red" -gt "$EXPECTED_RED" ]; then + echo "::error title=More tagged tests failing::$red of $run tagged tests fail, up from $EXPECTED_RED." + exit 1 + fi + echo "$red of $run tagged tests fail, as expected." + env: + # Keep in step with the @Tag(DEAD_CODE) annotations in src/test. + EXPECTED_TAGGED: '17' + EXPECTED_RED: '17' + + # The jar handed to testers and attached to releases. It is built here because it was + # previously assembled by hand outside the repository, which is how it came to ship + # without the two look-and-feel fixes below. + - name: Build the runnable jar + run: mvn -B -ntp -Pdist package -DskipTests + + # Building it is not the same as checking it. Shade succeeds whether or not the manifest + # carries Add-Exports and whether or not WinLaF's classes made it in, so a build-only step + # would go green on exactly the artifact that crashes for a user. Both halves are executed + # here, and the second is executed against a deliberate negative so the check cannot + # quietly become decorative. + - name: Verify the runnable jar + run: | + set -euo pipefail + jar=target/PokEditor-3.2.0-dist.jar + javac -cp "$jar" -d target/probe .github/probe/JideResolutionProbe.java + + # Continuation lines in a manifest begin with a single space and the break can fall + # mid-token: this value spans three lines and currently splits sun.awt.shell across + # two. Which tokens split moves with the value, so the raw file has to be unfolded -- + # otherwise a present package can read as absent, and a split one becomes a garbage + # --add-exports flag rather than an error. + exports=$(unzip -p "$jar" META-INF/MANIFEST.MF | python3 .github/probe/manifest_value.py Add-Exports) + main=$(unzip -p "$jar" META-INF/MANIFEST.MF | python3 .github/probe/manifest_value.py Main-Class) + echo "Main-Class: $main" + echo "Add-Exports: $exports" + + if [ "$main" != "io.github.turtleisaac.pokeditor.Main" ]; then + echo "::error title=The jar is not runnable::Main-Class is '$main'. Launching it with java -jar will fail." + exit 1 + fi + + # 1. This runner's own conditions. No JDK outside Windows ships + # com.sun.java.swing.plaf.windows, so the system-scoped WinLaF.jar is the only thing + # satisfying the resolution -- and the shade plugin drops system scope unless the + # pom unpacks it. Nothing in src/main names that class, so this step is what stands + # between someone deleting WinLaF.jar as dead weight and a user hitting + # NoClassDefFoundError on their first keystroke in a sheet. + if ! java -cp "$jar:target/probe" JideResolutionProbe; then + echo "::error title=WinLaF classes are missing from the jar::The look-and-feel class jide-oss resolves is absent, so building any JIDE component dies with NoClassDefFoundError. The dist profile unpacks WinLaF.jar because system is neither compile nor runtime and the shade plugin resolves neither." + exit 1 + fi + + # 2. Windows. There the JDK does ship that package, inside java.desktop, which does not + # export it -- so resolution fails with IllegalAccessError no matter what is on the + # classpath, and only the manifest entry helps. The condition is reproduced by + # patching the class into java.desktop so it is present but encapsulated, exactly as + # it is there, and by giving jide-oss an os.version it recognises: its table stops at + # 6.2, so on Windows 10 and 11 its own isWindowsVistaAbove() is false and the crash + # arrives by a different branch. + # + # The exports the jar itself declares are what get applied, so trimming the manifest + # fails this step. On the command line the JVM warns about the packages java.desktop + # does not have on Linux; in the manifest form the shipped jar uses, it is silent. + mkdir -p target/winpatch + unzip -qo "$jar" 'com/sun/java/swing/plaf/windows/*' -d target/winpatch + flags="" + for token in $exports; do flags="$flags --add-exports $token=ALL-UNNAMED"; done + + if ! java -Dos.name="Windows 7" -Dos.version="6.1" \ + --patch-module java.desktop=target/winpatch $flags \ + -cp "$jar:target/probe" JideResolutionProbe; then + echo "::error title=The jar would crash on Windows::The Add-Exports entry in its manifest does not cover com.sun.java.swing.plaf.windows, so typing into a combo box in a sheet throws IllegalAccessError. See the dist profile in pom.xml." + exit 1 + fi + + # The negative. If this passes, the check above proves nothing -- either the simulation + # stopped reproducing the Windows condition or the probe stopped reaching the code that + # fails, and in both cases the step needs fixing rather than trusting. + echo "Negative control (an IllegalAccessError here is the expected result):" + if java -Dos.name="Windows 7" -Dos.version="6.1" \ + --patch-module java.desktop=target/winpatch \ + -cp "$jar:target/probe" JideResolutionProbe >/dev/null 2>&1; then + echo "::error title=This step no longer proves anything::The probe passed without any --add-exports, so it is not reaching the resolution that fails on Windows. Fix the check; do not delete it." + exit 1 + fi + echo "Verified: resolves on this runner, resolves under the Windows condition, and fails without the manifest entry." + + - name: Upload the runnable jar + uses: actions/upload-artifact@v4 + with: + name: PokEditor-jar + path: target/PokEditor-3.2.0-dist.jar + if-no-files-found: error + + - name: Upload build output + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + **/target/surefire-reports/** + **/target/*.log + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 2188fcc..1adb542 100755 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ /Found/ /Gen 5 Editing Information/ /GoogleSheetsAPI/out/ + +# Maven build output +/target/ diff --git a/README.md b/README.md index ab4a752..2e7f312 100755 --- a/README.md +++ b/README.md @@ -26,6 +26,97 @@ Builds are available in the [Releases page here on GitHub](https://github.com/tu Unlike prior versions of PokEditor, v3 is intended to fully be used within the tool. No more exporting sheets or editing them elsewhere. Additionally, there is very little effort required by the user to get it set up this time around. No more annoying sheets setup process, it should just automatically load everything into the sheets the instant you open a project. +# Building from source + +Requires a JDK (the module compiles at source/target 20; CI builds on 21) and Maven. + +### 1. Install the sibling libraries first, in this order + +None of the three are on Maven Central at the versions this pom pins, so each has to be built +and installed locally before PokEditor will resolve. Order matters: Nds4j underpins the other +two. + +``` +git clone https://github.com/turtleisaac/Nds4j.git && mvn -f Nds4j/pom.xml install -DskipTests +git clone https://github.com/turtleisaac/Nds4j-ToolUI.git && mvn -f Nds4j-ToolUI/pom.xml install -DskipTests +git clone https://github.com/turtleisaac/PokEditor-Core.git && mvn -f PokEditor-Core/pom.xml install -DskipTests +``` + +When a change spans several of these repositories it is developed on branches of the same name +in each, so check out matching branches before installing. CI does this automatically, falling +back to `main` where no matching branch exists. + +> **A clean checkout does not build yet.** `pom.xml` also declares +> `io.github.turtleisaac:VariableTracker:1.0-SNAPSHOT`, which is published nowhere the build can +> reach and has no `` entry. The field script editor is disabled, but +> `ScriptDocument` still imports the library, so it is still needed to compile. See +> `TECH_DEBT.md`. + +### 2. Build the runnable jar + +``` +mvn clean -Pdist package +``` + +This produces **`target/PokEditor-3.2.0-dist.jar`**, which is the artifact to hand to a tester or +attach to a release — every dependency in one archive, launched with `java -jar`. Add +`-DskipTests` to skip the suite. + +The plain `mvn package` deliberately does **not** produce it: `target/PokEditor-3.2.0.jar` holds +this module's classes without its dependencies and is not runnable on its own. `dist` is a +separate profile because it costs ~17MB of output that nothing in CI consumes. (Under `-Pdist` +that plain jar also picks up the unpacked `WinLaF` classes as a side effect of the step below. +Nothing distributes it, so this is harmless — but it is why a `-Pdist` build should start from +`clean`.) + +### Do not hand-assemble the jar + +The `dist` profile does two things that are easy to miss, and getting either wrong produces a +jar that starts fine and then dies the first time someone types into a combo box in a sheet: + +- **It writes an `Add-Exports` manifest entry.** jide-oss predates the module system and does + `instanceof com.sun.java.swing.plaf.windows.WindowsLookAndFeel` on a path every JIDE component + reaches. On Windows that package lives in `java.desktop`, which does not export it, so the + check fails with `IllegalAccessError` before it can answer false. +- **It unpacks `WinLaF.jar`.** That dependency is `system`, and the shade plugin + resolves only compile and runtime scope, so it is dropped silently. On Linux and macOS no JDK + ships that package at all and this copy is the only thing satisfying it, so without it the + same code path dies with `NoClassDefFoundError` instead. + +Neither substitutes for the other — on Windows, parent-first delegation finds `java.desktop`'s +copy and shadows `WinLaF.jar` entirely. `TECH_DEBT.md` has the full account. + +### CI builds this too + +Every push builds the same jar and uploads it as a run artifact named **PokEditor-jar**, so a +tester can download it from the Actions run rather than waiting for a release. CI also verifies +it: it runs the look-and-feel resolution described above against the built jar, once under this +runner's own conditions and once under a simulated Windows one, and then checks that the second +fails when the manifest entry is withheld — because a check that passes either way would go green +on exactly the artifact that crashes. + +### 3. Run it + +``` +java -jar target/PokEditor-3.2.0-dist.jar +``` + +### Running the tests + +``` +mvn verify -Djava.awt.headless=true -DexcludedGroups=dead-code +``` + +This is what CI's `Build` step runs, and it must stay green. The `dead-code` tag marks tests for +classes with no callers anywhere in `src/main` — they are the specification for whoever revives +or deletes that code, not defects anyone can hit. They are expected to fail, so omitting +`-DexcludedGroups` will report failures on a healthy tree. + +Those tests run in CI's separate `Known dead-code failures` step, which pins both the tagged +count and the failure count. Pinning them means a regression cannot be hidden by tagging it, and +a fix prompts someone to remove the tag — so if you add or fix a tagged test, update +`EXPECTED_TAGGED` / `EXPECTED_RED` in `.github/workflows/build.yml` and say why in the commit. + # List of Spreadsheet-Based Editors * Personal Data Editor diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 0000000..aa25b7b --- /dev/null +++ b/TECH_DEBT.md @@ -0,0 +1,503 @@ +# Known defects and technical debt + +Everything here was found during a bug-hunting pass across PokEditor and its three +supporting libraries, and everything here was **reproduced before being written down** — +either by running it or by reading the code path end to end. Where an item is a judgement +call rather than a defect, that is said. + +Items are grouped by where they live and ordered by how much damage they do. Each says +what breaks, what it costs, and — where the answer is not obvious — what makes it awkward +to fix. + +A `@Tag("dead-code")` test exists for several of these. Those run in a separate CI job that +is expected to fail, with the count asserted, so that a fix or a regression both show up. + +--- + +## PokEditor + +### Row add/delete is unsound on species-indexed sheets +**Severity: high. Reachable in one click.** + +Personal, TM compatibility, Evolutions and Learnsets are parallel tables indexed by species +ID. Deleting a row renumbers every entry after it, so anything referring to those entries +by index — evolutions, learnsets, encounters, trainers — now points at the wrong species. + +The cross-sheet half of this is fixed: the sheets share one species-name bank, and a +deletion on one used to shift the names on the others silently, which the next save wrote +to the ROM. The other sheets are now told, so the display no longer lies. + +What remains is the operation itself. Either the row buttons should be removed from these +sheets, or "delete row" should mean "delete this species everywhere", which is a +coordinated change across four sheets and the name bank. Until then a deletion is a +renumbering the user is unlikely to be expecting. + +### jide-oss reaches into encapsulated JDK packages, and breaks differently on each platform +**Severity: was high on Windows - one keystroke, every user. Fixed in packaging; the hazard +remains.** + +`LookAndFeelFactory` chooses a style with +`lnf instanceof com.sun.java.swing.plaf.windows.WindowsLookAndFeel`, on the branch taken for +every look and feel it does not recognise - which includes FlatLaf, the one this application +sets. Every JIDE component runs it: `JidePopup.updateUI()` calls `installJideExtension()`, and +`updateUI()` runs from the JComponent constructor. This application arrives there whenever +someone types into a combo box in a sheet, because `EditorComboBox` installs a +`ComboBoxSearchable` whose search popup is a `JidePopup`. + +An `instanceof` resolves its class before it can answer false, so the platform decides the +failure: + +| | `com.sun.java.swing.plaf.windows` in `java.desktop`? | failure | what fixes it | +|---|---|---|---| +| Windows | yes, not exported | `IllegalAccessError` | `Add-Exports` in the jar manifest | +| Linux, macOS | no | `NoClassDefFoundError` | `WinLaF.jar` on the classpath | + +**Neither fixes the other.** On Windows, parent-first delegation finds `java.desktop`'s copy and +shadows `WinLaF.jar` entirely, which is why the classpath copy that has always been there never +helped; `Add-Exports` naming a package a module does not have is ignored silently, which is why +carrying it on every platform is free. Both were reproduced and both fixes verified, including +against a `--patch-module` simulation of the Windows condition. + +The `dist` profile now writes that manifest entry, and `JideLookAndFeelResolutionTest` guards +the classpath half - it fails if `WinLaF.jar` is dropped as apparent dead weight, which nothing +else would catch, since nothing names the class. + +What remains is the dependency itself. jide-oss 3.6.18 is from 2015, predates the module system, +and also reaches `sun.awt`, `sun.awt.windows`, `sun.awt.image`, `sun.awt.shell` and +`com.apple.laf`; those are exported pre-emptively so the next one reached is not a second bug +report. It is used for exactly one class, `ComboBoxSearchable`, to give combo boxes type-to- +search. Replacing that one behaviour would remove a 2MB dependency, the `WinLaF.jar` +system-scoped hack, and this entire class of failure. That is the real fix and it is not done. + +Note also that the version detection is stale: JIDE knows `os.version` 6.0, 6.1 and 6.2, so on +Windows 10 and 11 its own `isWindowsVistaAbove()` returns false. The crash arrives through the +`XPUtils` branch instead. Any workaround keyed on JIDE's idea of the OS would miss. + +### ~~`VariableTracker` is not published, so the project cannot be built from a clean checkout~~ (resolved: vendored) +**Was: severity high for contributors, none for users. Now resolved.** + +`pom.xml` used to declare `io.github.turtleisaac:VariableTracker:1.0-SNAPSHOT`, which existed in no +repository the build could reach and had no `` entry - only a local `~/.m2` copy and +one un-remoted clone. CI could not build this module, and neither could a new contributor. + +Resolved by **vendoring**: the `io.github.turtleisaac.variabletracker` package (models +`ScriptVariable`/`ScriptFlag`, the `VariableTracker`/`FlagTracker` Swing panels, hex cell helpers, +their `.jfd` forms, and the `variable_tracker/*.properties` resources) now lives under +`src/main/java` and `src/main/resources`, and the Maven dependency is gone. Its only third-party +needs - Jackson, FlatLaf, MigLayout - were already declared by PokEditor. A +`ScriptVariable(String, int)` constructor was added to satisfy `ScriptDocumentHighlightingTest`, +which the published artifact never had. + +### The field script editor is disabled +**Deliberate, not a defect.** It is the one editor that compiles scripts back into the ROM, +so a half-working version damages a project rather than merely disappointing. Its +construction is commented out in `PokeditorManager`; the subsystem and its 202 tests are +intact. + +### `CheckBoxEditor` turns anything that is not a Boolean into `false` +**Latent, not reachable today. Recorded because what keeps it unreachable is not obvious.** + +It holds no copy of the value it was opened with, so `getCellEditorValue()` reports the +checkbox's state whatever arrived: null, an Integer and a String all commit as `false`. Opening a +cell would change it - the same defect fixed in the combo box and numeric editors. + +Nothing reaches it. Every live CHECKBOX column reads an element of a `boolean[]` - the eight +Moves flags, the whole TM compatibility grid, Personal's FLIP - and the switch that serves them +covers every enum constant that maps to CHECKBOX, so the `return null` fallback below it is +unreachable for those columns. + +The part worth writing down is TM compatibility, where `getCellType` returns `CHECKBOX` for +**every** column, including the two frozen ones. The species-name column is a String with a +checkbox editor declared for it. That is harmless only because the frozen model overrides +`isCellEditable` to false - so a change making a frozen column editable would write `false` over +a species name, and nothing in the type declarations would suggest why. + +Not fixed rather than fixed speculatively: preserving the original value would hand null to a +write path that casts it to Boolean, so the fix is to stop the unconditional `getCellType` +claiming a type the column does not have, not to teach the editor to pass a value through. + +### `jokes.txt` has never been committed +`Main` reads `/pokeditor/jokes.txt`, which is in no commit on any branch. Startup no longer +dies without it, but the start screen shows nothing. The file is the project owner's +content and has not been invented. + +### Two sheets share one data list with independent models +Personal and TM compatibility are handed the same `List`. Row changes on one +leave the other's *selection model* holding an index the data no longer has, so a +subsequent `getValueAt` on that row throws. The row count itself is fine — `getRowCount` +delegates live to the model. + +Narrower than the name-bank problem above and largely self-healing on a repaint, but it is +the same shape: shared state, independent views. + +### The save confirmation does not name every file it writes +The dialog lists the files from the parser's declared requirements, which for Personal is +the personal NARC only. The same save also writes arm9, because preparing the data mutates +it. The dialog promises a complete list and omits the one file whose corruption is hardest +to recover from. + +### Reloading a sheet does not restore the name bank +`resetData` re-reads one data class. A row added or deleted beforehand has already changed +the shared `TextBankData`, which is not reloaded — so reload does not undo the edit. + +Fixing it needs care: `TextBankData` is one dirty flag for the whole application, so +reloading it wholesale would discard unsaved move, item and ability name edits made from +other sheets, silently, and then mark everything clean. + +### Dead code with no callers +`CsvReader`, `ArrayProcessor`, `XmlReader`, `BitStream`, `JarClassLoader`, `BitVector`, +`Directory`, `SheetExceptionFactory`, `JCheckboxTree`, `CircleButton` have no references in +`src/main`. They carry real defects — `XmlReader` tests for a close tag spelled with a +backslash and so cannot read well-formed XML at all; `ArrayProcessor` drops trailing empty +fields; `BitStream(0)` cannot grow — but nothing can reach them. Their tests are tagged +`dead-code`. + +The decision to leave them as-is is deliberate. The open question is whether to delete them +or revive them, not whether to fix them in place. + +### `DataManager` caches are process-wide with no reset +Static maps with no clear hook, which is why testing them needs reflection. They are scoped +to the ROM they were filled from, but nothing in the application can open a second ROM in +one session — the three menu entries that would are unimplemented and closing the tool +frame exits the process. The scoping is a guard against a future capability. + +--- + +## Nds4j — **this library is published to Maven Central, where a released version is permanent** + +### A scanned sprite is destroyed on save if its top-left pixel is not index 0 +**Severity: high. Live in the product.** + +The scanned NCGR decoder derives its decryption key from the first word of the *ciphertext*, +so a save/load round trip is only self-consistent when the first word of the *plaintext* is +zero. Edit the top-left pixel of a battle sprite, save, reload, and every row differs. + +Reachable through the sprite editor's import and its left/right swap. No format +compatibility cost to fixing it, so it can be fixed in a patch release — but it destroys +user work today. + +### The scanned NCGR path has no coverage that runs in CI +Every test touching a scanned image needs a retail ROM and skips without one. That is how an +8bpp under-allocation survived indefinitely and a 4bpp regression was introduced on top of +it with nothing going red. + +Worse, the one scanned test that exists is a re-parse comparison, and a re-parse **cannot** +detect a wrong decryption key: `save()` re-encrypts from whatever key the decode returned, +so seed and direction errors cancel exactly and produce garbage pixels with a byte-identical +round trip. Verified by experiment. + +A workflow appeared to cover this and never did. `maven-verify.yml` downloaded a ROM and ran the +full suite, but it moved the file into the workspace *before* `actions/checkout`, which cleans +untracked files and deleted it - and it ran on JDK 8, which cannot compile test sources using +`java.util.HexFormat`. Two independent faults, so the ROM suite has never run in CI here at all. +It obtained the ROM by reconstructing it with `xdelta3` against an empty source, meaning the +archive it fetched held the entire commercial game; that step is gone, and the workflow now takes +`-Drom.dir` so a runner with a legally obtained copy can point at one. Hosted runners have none, +so this gap stays open until such a runner exists or the fixture below does. + +The fix is a committed synthetic fixture with expected pixel values derived from an +independent implementation — DSPRE's decoder is separately written and agrees — asserting +decoded pixels rather than the round trip. + +### Cell banks of type 0 render at 8x8 and paste out of bounds +Type 0 NCER banks carry no bounding rectangle, so the cell image is sized from zeroes and +OAMs are pasted at raw, often negative, coordinates. + +The fix is to derive the bounds from the OAMs themselves. Two constraints make it less +obvious than it looks: the derived bounds have to reach `CellImage.save()`, which reads the +`Cell` fields directly, so keeping them local just moves the crash to the save path; and +writing them back is byte-neutral **only** for type 0, because the writer omits those fields +for that type. Triggering on a degenerate rectangle instead of on the bank type would +silently rewrite a stored field on a type 1 bank. + +A cell with no OAMs at all also needs a fallback — min and max over an empty set are +undefined. + +### Scanned 8bpp images can be read but never written +`save()` throws for scanned 8bpp. Anything round-tripping a NARC containing one loses it. +Shipping a read-only depth is a permanent asymmetry in the public API, and it cannot be +removed later without a behaviour change. + +The commented-out encoder appears correct — transcribed and run against the live decoder, it +reproduces the original ciphertext byte for byte in both scan directions. It needs its +buffer sized from the tile count rather than the pixel dimensions, as the 4bpp path now is. + +### `getNcerImage` uses a different placement convention from the rest of the class +A hardcoded 80x80 canvas with a centre origin, carrying a `//todo undo this being 80`. It +matches Tinke's renderer, so it is inconsistent rather than wrong, but two conventions now +coexist in one class. + +### `MemBuf` changed several published contracts in one release +`readByte()` returns unsigned where it returned signed; `readString` decodes ISO-8859-1 +where it decoded UTF-8; `writeString(s, len)` truncates where it threw; `skip` rejects a +negative count; `align` no longer pads an already-aligned buffer. + +Every in-tree caller normalises explicitly and so is unaffected, but these are behavioural +changes to a published surface and belong in the release notes. `align` is +format-visible — NARCs written after the change are not byte-identical to those written +before. + +Worth considering before the version is permanent: `readByte()` is now a duplicate of +`readUInt8()`, no signed byte reader survives, and the name contradicts +`DataInput.readByte()`. A deprecation and a clearly-named pair would cost nothing now and +cannot be done cheaply later. + +### `getEncryptionKey()` uses -1 to mean "no key" +`0xFFFFFFFF` is a legal key, so the sentinel is ambiguous in principle. In practice it is +unreachable: producing it requires a stream of 61,741 words — 246,964 pixels — which no DS +VRAM bank can hold. Recorded because the reasoning is not obvious, not because it needs +fixing. + +The real defect at those lines is that `save()` passes the sentinel through as a live seed +without checking. + +### Smaller items +- `Palette(int)` accepts any size; `Palette(Color[])` rejects more than 256. The first will + happily emit a 1000-colour NCLR. +- The NCGR parse constructor has no positive-dimension guard, so a 48-byte file yields a + zero-height image. +- `getSubImage` copies `scanMode` and `encryptionKey` onto a freshly laid-out image, where + neither applies. +- Sizing the scanned decode buffer from the tile count means a truncated file now fails with + "Not enough room to read" rather than a format error. It belongs in the malformed-input + suite. + +--- + +## PokEditor-Core + +### The parsers have no coverage that runs in CI +Round-trip tests against real game data need a retail ROM and skip without one. Of the tests +that do run, most assert only that a parser is non-null. **A green build here says very +little**: no format's parse is exercised. + +Run with `-Drom.dir=` before trusting a change to a parser. Expect failures — the +round-trip assertions were strengthened, and the original author's own code counted "valid +but non-1:1 matching" script files, which suggests scripts do not round-trip byte-exactly. + +### `isEndCommand` treats `endstd` as terminating a run +An unannounced change to script traversal. If it is wrong, commands after an `endstd` are +never visited and are silently dropped on save. The macro file documents command 21 as +"Yield to parent context", so it is probably right — but it is the most consequential line +in the script reader and it is unverified against a real ROM. + +### Abort-versus-tolerate is unresolved on the text and script load paths +`TextBankData` bounds checks and `offsetObtainer` now abort where they previously degraded. +For a retail ROM this is fine. For a hacked ROM — which is the population this tool +serves — it converts a file that opened with one garbled entry into a file that will not +open at all. + +The same question was already settled the other way for evolution files, where a cap fixed +at the retail entry count made expanded tables unsavable. These two paths should be +consistent, and currently are not. + +--- + +## Nds4j-ToolUI + +### Only the file helper is tested; the module's actual job is not +**Severity: this is where the three items below live.** + +3,834 lines of `src/main` against 588 of tests, and the tests reach two files: `FileUtils` and +the hexadecimal spinner's formatting. `Tool` (1,299 lines) has none, nor do `ToolFrame`, +`PanelManager`, `ProjectCreateDialog`, `ProjectStartPanel` or `ThemeUtils`. + +That is the module owning project open and save, the save lock, and the git integration - and the +three defects below are all in `Tool`, `ToolFrame` and `PanelManager`, which is not a +coincidence. `FileUtils.atomicWrite` was the one part with tests, and it is the one part that got +fixed properly, because the tests could show what was wrong. + +Worth stating precisely, because the raw numbers mislead in the other direction: the atomic-write +tests are genuine. Three of them turn on POSIX permissions and skip when the suite runs as root - +which is what a container does, so a local run reports `25 passed, 3 skipped`. Hosted runners are +not root, and CI reports `25 passed, 0 skipped`. Those three are the ones with teeth: they are +what proved the previous durability test asserted nothing. + +### ~~The backup commit failed for anyone who signs commits with SSH~~ - fixed +`gpg.format = ssh` in a user's global config made JGit refuse the repository, with an unchecked +`IllegalArgumentException` that escaped the worker's handlers and reached the user as "an +unexpected error occurred" after **every save**. The save had worked; only the backup was missing, +and nothing distinguished the two. + +Needed both a bump to JGit 7.1.0 and an explicit `setSign(false)` - on 6.7 and 6.10 the commit +throws whatever `setSign` says, and on 7.1 it throws unless signing is explicitly off. Signing an +automatic local backup was never meaningful anyway. + +PokEditor also declared JGit itself, at 6.7.0, and a direct dependency beats a transitive one - so +upgrading ToolUI alone would have left the shipped application unchanged. That declaration is +removed rather than bumped: nothing in PokEditor references JGit, and pinning it again is how the +versions drift apart in the first place. **Let ToolUI own this version.** + +The general lesson is worth more than the fix: no test had a repository configured the way a real +user's is. It surfaced only because this container happened to have that setting. + +### ~~A multi-file save is not atomic~~ - fixed, with a named remainder +`FileUtils.atomicWrite` is split into staging and the rename it ends with, and `atomicWriteAll` +stages every entry before moving any of them. `Tool.SaveBatch` collects sections and writes them +as one; PokEditor's sheet save is now a single batch. + +Staging is where a save actually fails - a full disk, a read-only file, a bad path - so those now +happen while every target is still the old version. + +**What is not fixed**, and the javadoc says so rather than claiming otherwise: the run of renames +at the end is not one operation. Each is atomic alone, but a process killed between two of them +still leaves some files new and some old. Closing that needs a journal and a recovery pass on +open, which would be the next step if it is ever wanted. + +### ~~Nine `writeModified*` methods document a boolean they cannot return~~ - fixed +They return `void` and throw. `writeProjectInfo` keeps its boolean, which has always meant +something. This is a source-breaking change for any caller testing the result, which is why it was +worth doing before 1.0.0 rather than after. + +### ~~The git worker holds the save lock for the duration of a commit~~ - fixed +Held across staging only. The reason recorded here for holding it - that a partially written file +could be committed - was **wrong**: files are written through a temporary and renamed, so no file +is ever visible in a partial state. The real reason is that a save landing mid-staging commits +some files new and some old, and that reason justifies staging but not committing. + +A save that still has to wait now waits a bounded time and says why, instead of freezing the +interface with nothing said. That is a mitigation, not a cure: saving still happens on the event +thread, and moving it off is the actual fix. + +--- + +## Dependency declarations + +Swept after the JGit pin turned out to be a class of problem rather than one mistake. The rule +that came out of it: **a module declares what it imports, and nothing else.** Both halves were +being broken. + +*Declaring what is not used* - a direct declaration overrides what an upstream dependency asks +for, and since nothing imports it, a wrong version cannot fail to compile. JGit was this, and +shipped broken. Also removed: `jsvg` from ToolUI and PokEditor, which belongs to flatlaf-extras +and was pinned one version ahead of what flatlaf asks for; `flatlaf-intellij-themes` from ToolUI, +which never referenced it - `ThemeUtils` holds an empty list and the consumer supplies themes; +`jackson-dataformat-xml` from all three; `junit:junit` from Core and PokEditor, both entirely on +JUnit 5. + +*Using what is not declared* - the same sweep proved why this matters. Removing +`jackson-dataformat-xml`, which nothing imported, also removed the only route to +`jackson-databind`, which several modules do import, and the build stopped compiling. +`jackson-databind` and `jackson-core` are declared explicitly now. + +Guice moved to test scope in Core, where nothing in `src/main` imports it. + +Two things worth keeping in mind for the next sweep: + +- **`dependency:analyze` is a report, not a verdict.** It reads bytecode, so it called `jsvg` and + `flatlaf-intellij-themes` unused in PokEditor, which genuinely uses both - through runtime + loading it cannot see. Every entry above was checked against the source. +- **A dependency change proves nothing without `clean`.** The first pass here reported a green + build on stale `target/classes`: the poms had changed but no source had, so nothing was + recompiled. The clean build failed immediately. + +### The remaining hazard, unfixed +`Nds4j` is declared at `1.0.0` in three poms, and `assertj` and `junit-jupiter` in all four. +Nothing disagrees today - every shared artifact was checked - but there is no parent pom or +`dependencyManagement`, so each is a place the versions can drift apart silently. That drift, +already happened, is what the JGit bug was. + +--- + +## Building and releasing + +### What is published, and what is not +| Artifact | Version | On Central | +|---|---|---| +| `Nds4j` | 1.0.0 | **yes** - jar, pom, sources, javadoc and `.asc` all resolve | +| `Nds4j-ToolUI` | 1.0.0 | no - 404 | +| `PokEditor-Core` | 1.0.0 | no - 404 | +| `PokEditor` | 3.2.0 | n/a - an application, not a library | + +Nds4j 1.0.0 is **permanent**. A published coordinate cannot be replaced, which is why the +Nds4j section above is stricter about its API than the other three. + +### Building the chain from a clean checkout +Only Nds4j resolves on its own. ToolUI and Core have to be installed locally before PokEditor +can compile, in dependency order: + +``` +mvn -q install -DskipTests -f Nds4j/pom.xml # only if you changed it +mvn -q install -DskipTests -f Nds4j-ToolUI/pom.xml +mvn -q install -DskipTests -f PokEditor-Core/pom.xml +mvn verify -f PokEditor/pom.xml -DexcludedGroups=dead-code +``` + +Nds4j's line is optional because 1.0.0 comes from Central. Include it when testing a change to +Nds4j against its consumers - installing a snapshot locally is now the way a cross-repository +change gets exercised end to end, and it is a better test than CI ever ran, because it exercises +the exact combination about to ship rather than whichever branches happened to share a name. + +**`-DexcludedGroups=dead-code` is not optional on PokEditor.** Without it the build goes red: +545 tests run, 17 of them fail, and every one of those 17 is a `@Tag("dead-code")` specification +that is *expected* to fail - the header of this file explains why they exist. With the exclusion +it is 528 passing and green. CI passes the same flag, which is why CI is green and a plain +`mvn verify` is not. Anyone following these steps without it will conclude the build is broken. + +From a cold local repository: the two sibling installs take about 25 seconds together, and +PokEditor's own `verify` about 17 more. + +### CI resolves Nds4j from Central rather than building it +ToolUI's and Core's workflows used to resolve a branch of the same name, clone Nds4j and install +it from source. That is removed. Once Nds4j was published and both modules pinned `1.0.0`, the +steps no longer affected what the build resolved; and once Nds4j's `main` moved on they installed +a coordinate nothing asks for, so the jar they built was discarded. CI now tests against the same +artifact a consumer downloads. + +The cost is real and is worth stating: **CI can no longer exercise a change that spans two +repositories.** Picking up new Nds4j behaviour means editing the version pin, which is a +reviewable line in a diff instead of an implicit consequence of a branch name; the combined test +moves to a local checkout of all four, as above. + +PokEditor's CI still builds **ToolUI and Core** from source, because neither is published and +there is no other way for it to resolve them. Nds4j is still in that loop and no longer needs to +be - a one-word change, not yet made. + +### A version bump propagates by hand +`Nds4j` is pinned at `1.0.0` in three poms. Nothing updates them automatically, and with the CI +source-builds gone nothing disagrees loudly either, so a bump is now three deliberate edits. + +Nds4j `main` currently declares `1.0.0` while sitting **8 commits past the `v1.0.0` tag**, having +gained `CellAnimation`, `Screen` and a rewritten `CellBank` since the release - so the name +"Nds4j 1.0.0" presently refers to two different jars. An open PR moves `main` to +`1.1.0-SNAPSHOT`, with a test asserting that an in-development version carries a qualifier. Until +it merges, a local `mvn install` of Nds4j `main` silently overwrites the published 1.0.0 in the +local repository with different code, which is the failure mode worth knowing about when +following the local build steps above. + +### Publishing +**Nds4j** publishes through `release.yml`, triggered by a tag. It has run once, on `v1.0.0` +(`ad9c08a`), and succeeded. The signature on Central is the evidence that the four repository +secrets exist and the signing key works - an earlier version of this file listed both as +outstanding prerequisites, and both were cleared by that run. An earlier version also claimed +publication was a prerequisite for merging, which was never true: nothing in CI resolved these +from Central at the time. + +**ToolUI and Core have no publishing path at all.** Neither has a `release.yml`, a +`central-publishing-maven-plugin`, a `maven-gpg-plugin` or a `maven-javadoc-plugin`, and neither +pom carries any of ``, ``, ``, ``, `` or `` - +all of which Central requires. This is an open decision rather than a defect: ToolUI is a library +other people are invited to build tools against, so it has a real case for being published; Core +is PokEditor's own data layer with no independent consumers, so it has much less of one. Until +either is decided, both stay source-built by whoever needs them. + +### ~~PokEditor's build is red and will stay red~~ - resolved +Every run used to fail at *Verify dependencies resolve*, on the unpublished `VariableTracker`, +skipping every later step including the jar build. Resolved by vendoring, as recorded above. + +### ~~Open decision blocking a permanent API~~ - settled +`CodeBinary.compressed` was private, had no getter, and was read nowhere - which is how it +carried an inverted value undetected. It is now public as `wasCompressed()`, and its tests ask +the object rather than reaching in by reflection. + +Past tense because the flag describes the data the binary was **constructed from**, not what it +holds now: the buffer is decompressed either way and `getSize()` is the decompressed length, so a +retail arm9 answers `true` while everything read out of it is plain. + +It went in as `isCompressed()` first, and that was wrong twice over. `Overlay` extends +`CodeBinary` and already has an `isCompressed()` - the compression bit the ROM's overlay table +stores, a different fact and a settable one - so the new method was silently overridden. Same +signature, no `@Override`, nothing from the compiler, and a `CodeBinary` reference to an +`Overlay` quietly answering the other question. The two can disagree, and a test now pins that +they do. Worth remembering as the shape of the risk: adding a name to a base class can capture a +subclass's existing method without a word from the compiler. diff --git a/pom.xml b/pom.xml index ad9e965..7669055 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.turtleisaac PokEditor - 3.0-SNAPSHOT + 3.2.0 20 @@ -15,18 +15,22 @@ + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + org.junit.jupiter junit-jupiter 5.9.2 test - - junit - junit - 4.13.2 - test - org.assertj assertj-core @@ -54,12 +58,6 @@ 3.2.1 - - com.github.weisj - jsvg - 1.2.0 - - com.miglayout @@ -74,19 +72,6 @@ 3.6.18 - - - org.eclipse.jgit - org.eclipse.jgit - 6.7.0.202309050840-r - - - - com.fasterxml.jackson.dataformat - jackson-dataformat-xml - 2.15.2 - - com.google.inject guice @@ -105,27 +90,24 @@ io.github.turtleisaac Nds4j - 0.1.0 + 1.0.0 io.github.turtleisaac PokEditor-Core - 1.0-SNAPSHOT + 1.0.0 io.github.turtleisaac Nds4j-ToolUI - 1.0-SNAPSHOT - - - io.github.turtleisaac - VariableTracker - 1.0-SNAPSHOT + 1.0.0 + - + io.github.turtleisaac WinLaF @@ -133,6 +115,144 @@ system ${project.basedir}/WinLaF.jar + + + org.junit.jupiter + junit-jupiter + 5.9.2 + test + + + org.assertj + assertj-core + 3.24.2 + test + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + -Djava.awt.headless=true --add-opens java.desktop/java.awt=ALL-UNNAMED + + + + + + + + + dist + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + unpack-winlaf + prepare-package + run + + + + + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + shade + + false + true + dist + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + io.github.turtleisaac.pokeditor.Main + + java.desktop/com.sun.java.swing.plaf.windows java.desktop/sun.awt java.desktop/sun.awt.windows java.desktop/sun.awt.image java.desktop/sun.awt.shell java.desktop/com.apple.laf + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/io/github/turtleisaac/pokeditor/DataManager.java b/src/main/java/io/github/turtleisaac/pokeditor/DataManager.java index 6bcd2f6..917b11d 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/DataManager.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/DataManager.java @@ -44,6 +44,8 @@ import java.lang.reflect.ParameterizedType; import java.util.HashMap; +import java.util.Objects; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -63,7 +65,8 @@ public class DataManager { List textData = DataManager.getData(rom, TextBankData.class); List data = DataManager.getData(rom, PersonalData.class); - DefaultSheetPanel sheetPanel = new DefaultSheetPanel<>(manager, new TmCompatibilityTable(data, textData)); + List moves = DataManager.getData(rom, MoveData.class); + DefaultSheetPanel sheetPanel = new DefaultSheetPanel<>(manager, new TmCompatibilityTable(data, textData, moves)); // String[] moveNames = textData.get(TextFiles.MOVE_NAMES.getValue()).getStringList().toArray(String[]::new); // sheetPanel.thing(new ComboBoxCellEditor(moveNames)); return sheetPanel; @@ -130,9 +133,65 @@ public static GenericParser getParser(Class eC private static final Map, List> dataMap = new HashMap<>(); private static final Map codeBinaries = new HashMap<>(); + /** + * The ROM the two caches below hold data for. + *

+ * They are keyed by data class alone, so nothing else distinguishes one ROM's parsed data + * from another's. Nothing in the application can currently open a second ROM in one session + * - the three menu entries that would are unimplemented stubs and closing the tool frame + * exits the process - so this is a guard against a future capability rather than a bug + * anyone has hit. It is kept because it costs four lines and the failure it prevents + * (writing one ROM's tables into another) is silent and unrecoverable. + *

+ * Compared by identity, not equality: two ROMs loaded from the same file are separate + * objects with separate edits, and treating them as interchangeable is the same bug again. + */ + private static NintendoDsRom cachedRom; + + /** + * Points the caches at the given ROM, discarding anything held for a different one. + *

+ * Deliberately does not clear the dirty flags. Whether unsaved work may be + * discarded is a decision for whoever switches ROMs, taken in front of the user; a cache + * coherency helper silently answering it means the exit prompt goes quiet and the edits + * vanish with no warning. + *

+ * Note that {@link #commitData} and {@link #saveCodeBinaries} both take a ROM and do not + * come through here. They act on data already prepared for a specific ROM rather than + * fetching any, so there is nothing for them to invalidate - but if ROM switching is ever + * implemented, commitData is where one ROM's narcs could be written into another. + */ + private static void useRom(NintendoDsRom rom) + { + if (cachedRom == rom) + return; + + dataMap.clear(); + codeBinaries.clear(); + cachedRom = rom; + } + + /** + * Fails rather than treating a missing ROM as a ROM. + *

+ * A null argument used to be harmless here, because the ROM was ignored whenever the cache + * already held the class. Once the caches became ROM-scoped it stopped being harmless: null + * read as "a different ROM", which discarded every loaded sheet and every code binary - and + * since the binaries are only ever populated once, at startup, that left the session unable + * to save anything again. Refusing loudly is the only version of this that cannot quietly + * answer for the wrong ROM. + */ + private static void requireRom(NintendoDsRom rom) + { + Objects.requireNonNull(rom, "A ROM must be provided - DataManager cannot resolve data " + + "without knowing which ROM it belongs to."); + } + @SuppressWarnings("unchecked") public static List getData(NintendoDsRom rom, Class eClass) { + requireRom(rom); + useRom(rom); if (dataMap.containsKey(eClass)) return (List) dataMap.get(eClass); @@ -149,24 +208,103 @@ public static List getData(NintendoDsRom rom, Cla return data; } - public static Set saveData(NintendoDsRom rom, Class eClass) + private static final Set> dirtyClasses = new HashSet<>(); + + /** + * Records that the in-memory data for the provided class has been edited but not yet + * written out, so the tool can prompt before discarding it on exit. + */ + public static void markDirty(Class eClass) { + if (eClass != null) + dirtyClasses.add(eClass); + } + + public static void markClean(Class eClass) + { + dirtyClasses.remove(eClass); + } + + public static boolean hasUnsavedChanges() + { + return !dirtyClasses.isEmpty(); + } + + /** + * Serialises the in-memory data for the provided class WITHOUT touching the ROM. + *

+ * This is deliberately side effect free so callers can show the user which files a save + * would write (and let them back out) before anything is actually modified - previously + * this method mutated the ROM up front, so declining the confirmation only skipped the + * write to disk while leaving the "cancelled" edits sitting in the in-memory ROM. + * @return the files which would be written, or null if the class has never been loaded + */ + public static Map prepareData(NintendoDsRom rom, Class eClass) + { + requireRom(rom); + useRom(rom); if (!dataMap.containsKey(eClass)) return null; GenericParser parser = DataManager.getParser(eClass); - Map map = parser.processDataList(getData(rom, eClass), codeBinaries); + return parser.processDataList(getData(rom, eClass), codeBinaries); + } + + /** + * Whether the given class has been parsed and is available to save. + *

+ * Answers the question {@link #prepareData} used to answer by returning null, but without + * serialising anything - so a caller can decide whether a save is possible before doing the + * work that has side effects. + */ + public static boolean isLoaded(Class eClass) + { + return dataMap.containsKey(eClass); + } + + /** + * The game files a save of the given class will write. + *

+ * Every parser's {@code getRequirements()} is exactly the key set of the map its + * {@code processDataList} returns, so this is the same list the prepared data would have + * yielded - available without running the preparation. That is what lets the confirmation + * dialog name the files before anything is serialised. + */ + public static List filesWrittenBy(Class eClass) + { + return DataManager.getParser(eClass).getRequirements(); + } + + /** + * Applies the result of {@link #prepareData(NintendoDsRom, Class)} to the in-memory ROM. + */ + public static void commitData(NintendoDsRom rom, Map map) + { + if (map == null) + return; + for (GameFiles gameFile : map.keySet()) { rom.setFileByName(gameFile.getPath(), map.get(gameFile).save()); } - - return map.keySet(); } + /** + * Discards the in-memory edits for the provided class and re-parses it from the ROM. + *

+ * Nothing is prepared until the user has confirmed a save, so a cancelled save leaves the + * ROM untouched and re-reading it here genuinely discards the unsaved edits. + *

+ * Preparation itself is not free of side effects - PersonalParser writes the TM table into + * the shared arm9 buffer - so a save that gets past the confirmation and then fails on a + * value the data refuses can still leave arm9 partly written. That is a failure rather + * than a cancellation, and re-reading does not undo it. + */ @SuppressWarnings("unchecked") public static void resetData(NintendoDsRom rom, Class eClass) { + requireRom(rom); + useRom(rom); if (!dataMap.containsKey(eClass)) return; @@ -174,23 +312,40 @@ public static void resetData(NintendoDsRom rom, Clas list.clear(); dataMap.remove(eClass); List newList = getData(rom, eClass); - dataMap.remove(newList); - + // getData has now cached its own freshly parsed list under eClass. The caller still holds + // the original list object, so the contents are moved into it and it is put back as the + // cached one - otherwise every open sheet would keep rendering the discarded edits. + // (This used to call dataMap.remove(newList), which passes a List to a Class-keyed map + // and therefore removed nothing at all.) list.addAll(newList); dataMap.put(eClass, list); + markClean(eClass); } public static void codeBinarySetup(NintendoDsRom rom) { + requireRom(rom); + useRom(rom); MainCodeFile arm9 = rom.loadArm9(); codeBinaries.put(GameCodeBinaries.ARM9, arm9); // codeBinaries.put(GameCodeBinaries.ARM7, rom.loadArm7()); - MemBuf.MemBufWriter writer = arm9.getPhysicalAddressBuffer().writer(); - int pos = writer.getPosition(); - writer.setPosition(0xBB4); //todo account for DP if I ever add back support - writer.writeInt(0); - writer.setPosition(pos); + // Through the lock, like every other arm9 write in the codebase. Not for mutual + // exclusion - nothing is concurrent at startup - but because lock() and unlock() are + // what maintain the buffer's cursors: unlock() extends the recorded size to whatever + // was written and puts the write cursor back at the end. getData() returns the bytes + // between the read and write cursors, so a hand-rolled restore that leaves the writer + // short truncates arm9 on save. This used to save and restore the position itself, + // which is a weaker copy of the same protocol. + arm9.lock(); + try { + MemBuf.MemBufWriter writer = arm9.getPhysicalAddressBuffer().writer(); + writer.setPosition(0xBB4); //todo account for DP if I ever add back support + writer.writeInt(0); + } + finally { + arm9.unlock(); + } } public static void saveCodeBinaries(NintendoDsRom rom, List codeBinaries) diff --git a/src/main/java/io/github/turtleisaac/pokeditor/Main.java b/src/main/java/io/github/turtleisaac/pokeditor/Main.java index e6c2793..1d8229b 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/Main.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/Main.java @@ -3,10 +3,14 @@ import com.formdev.flatlaf.intellijthemes.*; import io.github.turtleisaac.nds4j.ui.ProgramType; import io.github.turtleisaac.nds4j.ui.Tool; +import io.github.turtleisaac.nds4j.ui.ToolLog; import io.github.turtleisaac.pokeditor.gui.PokeditorManager; import io.github.turtleisaac.pokeditor.gui.ConsoleWindow; +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; /** @@ -19,13 +23,29 @@ public class Main public static void main(String[] args) throws IOException { - String[] mainMenuJokes = new String(Main.class.getResourceAsStream(jokesPath).readAllBytes(), StandardCharsets.UTF_8).split("\n"); + // Before the handler, and before anything prints: ToolLog replaces the two streams, and + // whatever captured them earlier keeps writing only to the originals. + ToolLog.begin("PokEditor"); + + installUncaughtExceptionHandler(); + + // The jokes file is decoration for the start screen, and it is not in the repository - + // Main has referenced it since before this branch, so a clean checkout dereferences null + // here and the application cannot start at all. Nothing cosmetic should be able to do + // that, whether the file is restored later or not. + String[] mainMenuJokes; + try (InputStream jokesStream = Main.class.getResourceAsStream(jokesPath)) + { + mainMenuJokes = jokesStream == null + ? new String[] {""} + : new String(jokesStream.readAllBytes(), StandardCharsets.UTF_8).split("\n"); + } // Locale.setDefault(Locale.CHINA); Tool tool = Tool.create(); tool.setType(ProgramType.PROJECT) .setName("PokEditor") - .setVersion("3.1.1") + .setVersion("3.2.0") // .setFlavorText("Did you know that Jay likes Moemon?") .setFlavorText(mainMenuJokes[(int) (Math.random()*(mainMenuJokes.length))]) .setAuthor("Developed by Turtleisaac") @@ -39,4 +59,36 @@ public static void main(String[] args) throws IOException .addPanelManager(() -> new PokeditorManager(tool)) .init(); } + + /** + * Without this, every exception which escapes onto the EDT (including the validation + * failures thrown by the data classes' setters) is only ever printed to a command line + * that a user running a double-clicked jar never sees. + */ + private static void installUncaughtExceptionHandler() + { + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + throwable.printStackTrace(); + + String message = throwable.getMessage(); + if (message == null || message.isBlank()) + message = throwable.getClass().getSimpleName(); + + final String finalMessage = message; + // Not "see the command-line". Double-clicking the jar is how this is launched, and on + // Windows that runs through javaw, which has no console - so the trace just printed + // went nowhere the user can reach, and the dialog was directing them to something that + // does not exist. Name the file instead, so a bug report can carry the stack trace + // rather than one line of message. + java.nio.file.Path log = ToolLog.getLogFile(); + final String where = log == null + ? "\n\nNo log file could be opened, so there are no further details to send." + : "\n\nThe full details are in:\n" + log.toAbsolutePath() + + "\n\nPlease include that file when reporting this."; + + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(null, + "An unexpected error occurred:\n" + finalMessage + where, + "PokEditor", JOptionPane.ERROR_MESSAGE)); + }); + } } \ No newline at end of file diff --git a/src/main/java/io/github/turtleisaac/pokeditor/framework/ArrayModifier.java b/src/main/java/io/github/turtleisaac/pokeditor/framework/ArrayModifier.java index 16fdc0a..19bfcc4 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/framework/ArrayModifier.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/framework/ArrayModifier.java @@ -25,7 +25,7 @@ public static Object[] getColumn(Object[][] arr, int col) for(int i= 0; i < arr.length; i++) { if (col >= arr[i].length) - break; + continue; // a short row leaves its own cell blank, it does not end the column ret[i]= arr[i][col]; } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/framework/BitVector.java b/src/main/java/io/github/turtleisaac/pokeditor/framework/BitVector.java index 6cfca0f..9509ef7 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/framework/BitVector.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/framework/BitVector.java @@ -6,21 +6,57 @@ public class BitVector { final long[] longs; + /** + * how many bits this vector was declared to hold. kept so that an out of range index can be + * rejected: the backing array is rounded up to a whole number of longs, so without it the + * slack bits at the top of the last long would silently accept indices past the end. + */ + private final int maxBits; + public BitVector(int maxBits) { - int numLongs = maxBits / Long.SIZE; + if (maxBits < 0) + throw new IllegalArgumentException("A bit vector cannot have a negative length: " + maxBits); + this.maxBits = maxBits; + // round up - otherwise new BitVector(100) would only allocate a single long + int numLongs = (maxBits + Long.SIZE - 1) / Long.SIZE; longs = new long[numLongs]; } + /** + * @return the number of bits this vector holds; valid indices are {@code 0..size()-1} + */ + public int size() { + return maxBits; + } + + /** + * Java truncates integer division toward zero and masks a shift count to its low six bits, + * so an unchecked negative index does not fail - {@code idx = -1} lands on + * {@code longs[0]} with {@code 1L << 63}, quietly flipping a real bit at the top of the + * first word. Every index therefore gets checked before it is used. + */ + private void checkIndex(int idx) { + if (idx < 0 || idx >= maxBits) + throw new IndexOutOfBoundsException("Bit index " + idx + " is outside 0.." + (maxBits - 1)); + } + + private static long mask(int idx) { + return 1L << (idx % Long.SIZE); + } + public void setBit(int idx) { - longs[idx/Long.SIZE] |= idx << (idx%Long.SIZE); + checkIndex(idx); + longs[idx/Long.SIZE] |= mask(idx); } public void clearBit(int idx) { - longs[idx/Long.SIZE] &= ~(idx << (idx%Long.SIZE)); + checkIndex(idx); + longs[idx/Long.SIZE] &= ~mask(idx); } public boolean isSet(int idx) { - return (longs[idx/Long.SIZE] & (idx << (idx%Long.SIZE))) != 0; + checkIndex(idx); + return (longs[idx/Long.SIZE] & mask(idx)) != 0; } public long[] toLongs() { diff --git a/src/main/java/io/github/turtleisaac/pokeditor/framework/CsvReader.java b/src/main/java/io/github/turtleisaac/pokeditor/framework/CsvReader.java index 8d91873..ebdc064 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/framework/CsvReader.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/framework/CsvReader.java @@ -2,8 +2,10 @@ import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -48,13 +50,21 @@ public CsvReader(String filePath, int firstX, int firstY) throws IOException private String[][] getData(String filePath, int firstX, int firstY) throws IOException { ArrayList fileLines= new ArrayList<>(); - BufferedReader reader= new BufferedReader(new FileReader(filePath)); - String line; - while((line= reader.readLine()) != null) + try (BufferedReader reader = Files.newBufferedReader(Path.of(filePath), StandardCharsets.UTF_8)) { - fileLines.add(line); + String line; + boolean firstLine= true; + while((line= reader.readLine()) != null) + { + if(firstLine) + { + firstLine= false; + if(!line.isEmpty() && line.charAt(0) == '\ufeff') // strip the UTF-8 BOM + line= line.substring(1); + } + fileLines.add(line); + } } - reader.close(); for(; firstY != 0; firstY--) { fileLines.remove(0); @@ -72,11 +82,64 @@ private String[][] getData(String filePath, int firstX, int firstY) throws IOExc thisLine= thisLine.substring(thisLine.indexOf(",")+1); } thisLine= thisLine.replaceAll("×","x"); - fileData[i]= thisLine.split(","); + fileData[i]= splitCsvLine(thisLine); } return fileData; } + /** + * Splits a single CSV record into fields as per RFC 4180 - commas inside a quoted field + * are part of the field, a doubled quote inside a quoted field is a literal quote, and + * trailing empty fields are preserved (which {@code split(",")} would silently drop, + * shifting every downstream column). + */ + static String[] splitCsvLine(String line) + { + ArrayList fields= new ArrayList<>(); + StringBuilder current= new StringBuilder(); + boolean inQuotes= false; + + for(int i= 0; i < line.length(); i++) + { + char c= line.charAt(i); + if(inQuotes) + { + if(c == '"') + { + if(i + 1 < line.length() && line.charAt(i + 1) == '"') + { + current.append('"'); + i++; + } + else + { + inQuotes= false; + } + } + else + { + current.append(c); + } + } + else if(c == '"') + { + inQuotes= true; + } + else if(c == ',') + { + fields.add(current.toString()); + current.setLength(0); + } + else + { + current.append(c); + } + } + fields.add(current.toString()); + + return fields.toArray(new String[0]); + } + public String[] next() { if(line == in.length) @@ -114,7 +177,14 @@ public void print() String red= "\u001b[31m"; String reset= "\u001b[0m"; - int[] columnWidths= new int[in.length]; + // sized by the widest ROW, because it is indexed by column below + int maxColumns= 0; + for (String[] strings : in) + { + maxColumns= Math.max(maxColumns, strings.length); + } + + int[] columnWidths= new int[maxColumns]; Arrays.fill(columnWidths,Integer.MIN_VALUE); for (String[] strings : in) diff --git a/src/main/java/io/github/turtleisaac/pokeditor/framework/Directory.java b/src/main/java/io/github/turtleisaac/pokeditor/framework/Directory.java index 7c94004..a966547 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/framework/Directory.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/framework/Directory.java @@ -1,7 +1,6 @@ package io.github.turtleisaac.pokeditor.framework; import java.io.File; -import java.util.Objects; public class Directory extends File { @@ -13,23 +12,32 @@ public Directory(String pathname) @Override public boolean delete() { - clearDirectory(this); - return true; + return clearDirectory(this); } - private void clearDirectory(File directory) + private boolean clearDirectory(File directory) { - for(File subfile : Objects.requireNonNull(directory.listFiles())) + File[] subfiles = directory.listFiles(); + if (subfiles == null) // not a directory, or unreadable + return false; + + boolean success = true; + for(File subfile : subfiles) { if(subfile.isDirectory()) { - clearDirectory(subfile); + success &= clearDirectory(subfile); } else { - subfile.delete(); + success &= subfile.delete(); } } - directory.delete(); + // super.delete(), not directory.delete(): for the top level call `directory` IS this + // Directory, so a virtual call would dispatch straight back into the override above and + // recurse until the stack ran out. Subdirectories arrive as plain File from listFiles(), + // so only the outermost call was ever affected - which is to say every single call. + boolean removed = (directory == this) ? super.delete() : directory.delete(); + return removed && success; } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/framework/XmlReader.java b/src/main/java/io/github/turtleisaac/pokeditor/framework/XmlReader.java index 665eb63..ed32474 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/framework/XmlReader.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/framework/XmlReader.java @@ -2,8 +2,9 @@ import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; @@ -30,31 +31,33 @@ public HashMap readFile() throws IOException { HashMap ret= new HashMap<>(); - BufferedReader reader= new BufferedReader(new FileReader(file)); - String line; - boolean first= true; - boolean second= true; - - while((line= reader.readLine()) != null) + try (BufferedReader reader = Files.newBufferedReader(Path.of(file), StandardCharsets.UTF_8)) { - if(first) - { - first= false; - } - else if(second) - { - ret.put("program",line.substring(1,line.length()-1)); - System.out.println("Program: " + line.substring(1,line.length()-1)); - second= false; - } - else + String line; + boolean first= true; + boolean second= true; + + while((line= reader.readLine()) != null) { - if(!line.equals("<\\" + ret.get("program") + ">")) + if(first) + { + first= false; + } + else if(second) { - ret.put(line.substring(3,line.indexOf('>')),line.substring(line.indexOf('>')+1,line.lastIndexOf('<'))); - System.out.println(line.substring(3,line.indexOf('>')) + ": " + line.substring(line.indexOf('>')+1,line.lastIndexOf('<'))); + ret.put("program",line.substring(1,line.length()-1)); + System.out.println("Program: " + line.substring(1,line.length()-1)); + second= false; } + else + { + if(!line.equals("<\\" + ret.get("program") + ">")) + { + ret.put(line.substring(3,line.indexOf('>')),line.substring(line.indexOf('>')+1,line.lastIndexOf('<'))); + System.out.println(line.substring(3,line.indexOf('>')) + ": " + line.substring(line.indexOf('>')+1,line.lastIndexOf('<'))); + } + } } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/PokeditorManager.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/PokeditorManager.java index 600734d..58b6814 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/PokeditorManager.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/PokeditorManager.java @@ -29,8 +29,11 @@ import java.awt.event.ActionEvent; import java.io.BufferedWriter; import java.io.File; -import java.io.FileWriter; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import java.util.List; @@ -90,13 +93,13 @@ public class PokeditorManager extends PanelManager static { try { - sheetExportIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/table-export.svg")); - sheetImportIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/table-import.svg")); - rowRemoveIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/row-remove.svg")); - rowInsertIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/row-insert-bottom.svg")); - searchIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/list-search.svg")); - clipboardIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/clipboard-copy.svg")); - copyIcon = new FlatSVGIcon(PokeditorManager.class.getResourceAsStream("/pokeditor/icons/svg/copy.svg")); + sheetExportIcon = loadIcon("/pokeditor/icons/svg/table-export.svg"); + sheetImportIcon = loadIcon("/pokeditor/icons/svg/table-import.svg"); + rowRemoveIcon = loadIcon("/pokeditor/icons/svg/row-remove.svg"); + rowInsertIcon = loadIcon("/pokeditor/icons/svg/row-insert-bottom.svg"); + searchIcon = loadIcon("/pokeditor/icons/svg/list-search.svg"); + clipboardIcon = loadIcon("/pokeditor/icons/svg/clipboard-copy.svg"); + copyIcon = loadIcon("/pokeditor/icons/svg/copy.svg"); sheetExportIcon.setColorFilter(ThemeUtils.iconColorFilter); sheetImportIcon.setColorFilter(ThemeUtils.iconColorFilter); @@ -123,6 +126,14 @@ to select which set of colors to use for the types on the sheets (lighter or dar } } + private static FlatSVGIcon loadIcon(String path) throws IOException + { + try (InputStream stream = PokeditorManager.class.getResourceAsStream(path)) + { + return new FlatSVGIcon(stream); + } + } + private List panels; private NintendoDsRom rom; @@ -134,12 +145,15 @@ public PokeditorManager(Tool tool) super(tool, "PokEditor"); rom = tool.getRom(); - baseRom = Game.parseBaseRom(rom.getGameCode()); + Game.BaseRomInfo baseRomInfo = Game.parseBaseRom(rom.getGameCode()); + baseRom = baseRomInfo.game(); gitEnabled = tool.isGitEnabled(); GameFiles.initialize(baseRom); TextFiles.initialize(baseRom); GameCodeBinaries.initialize(baseRom); - Tables.initialize(baseRom); + // The region is passed explicitly rather than read back off the Game enum, which used to + // carry it as mutable per-constant state shared across every ROM opened in the process. + Tables.initialize(baseRom, baseRomInfo.region()); DataManager.codeBinarySetup(rom); @@ -173,9 +187,12 @@ public PokeditorManager(Tool tool) DefaultSheetPanel movesPanel = DataManager.createMoveSheet(this, rom); movesPanel.setName("Moves Sheet"); - DefaultDataEditorPanel fieldScriptEditor = DataManager.createFieldScriptEditor(this, rom); - fieldScriptEditor.setName("Field Scripts"); - fieldScriptEditor.setPreferredSize(fieldScriptEditor.getPreferredSize()); + // The field script editor is not in a working state and is not built. It is left out + // rather than shown and broken: it is the one editor that can compile a script back + // into the ROM, so a half-working version of it damages a project rather than merely + // disappointing. + // + // To bring it back: restore the construction below and the panels.add further down. // JPanel fieldPanel = new JPanel(); @@ -189,22 +206,37 @@ public PokeditorManager(Tool tool) // panels.add(battleSpriteEditor); panels.add(movesPanel); // panels.add(encounters); - panels.add(fieldScriptEditor); +// panels.add(fieldScriptEditor); // disabled - see above // panels.add(placeholder); } public void saveData(Class dataClass) { - Set gameFileSet = DataManager.saveData(rom, dataClass); - if (gameFileSet == null) + // Both confirmations happen before anything is prepared. + // + // processDataList is not side effect free: PersonalParser writes the TM/HM table straight + // into the shared arm9 buffer, and PokemonSpriteParser does the same. Preparing first and + // asking afterwards therefore left arm9 already modified when the user said no - and the + // next confirmed save of any sheet wrote that cancelled edit to disk. Reloading could not + // undo it either, because the reload re-reads the TM table out of the arm9 it just + // mutated. Asking first removes the window entirely. + if (!DataManager.isLoaded(dataClass)) { JOptionPane.showMessageDialog(null, "A fatal error occurred while attempting to save.", "Abort", JOptionPane.ERROR_MESSAGE); return; } - List gameFiles = new ArrayList<>(gameFileSet); - gameFiles.addAll(DataManager.saveData(rom, TextBankData.class)); -// DataManager.saveCodeBinaries(rom, List.of(GameCodeBinaries.ARM9)); + // the files a save will write, taken from the parser's own requirements rather than from + // prepared output - the two are the same set for every parser in Core + List gameFiles = new ArrayList<>(DataManager.filesWrittenBy(dataClass)); + if (DataManager.isLoaded(TextBankData.class)) + { + for (GameFiles gameFile : DataManager.filesWrittenBy(TextBankData.class)) + { + if (!gameFiles.contains(gameFile)) + gameFiles.add(gameFile); + } + } StringBuilder stringBuilder = new StringBuilder("This operation will write the following files:\n"); for (GameFiles gameFile : gameFiles) @@ -235,24 +267,123 @@ else if (message.isEmpty()) } } + // Past this point the user has committed to the save. Serialising can still fail on a + // value the data refuses, which leaves arm9 partly written - that is a failure rather + // than a cancellation, and it behaved the same way before. + Map preparedData = DataManager.prepareData(rom, dataClass); + if (preparedData == null) + { + JOptionPane.showMessageDialog(null, "A fatal error occurred while attempting to save.", "Abort", JOptionPane.ERROR_MESSAGE); + return; + } + + Map preparedTextData = DataManager.prepareData(rom, TextBankData.class); + + // only now is anything actually modified + DataManager.commitData(rom, preparedData); + DataManager.commitData(rom, preparedTextData); + DataManager.saveCodeBinaries(rom, List.of(GameCodeBinaries.ARM9)); + + // One batch, not a file at a time. A sheet's save writes several NARCs and arm9, and + // stopping half way leaves a combination the ROM was never in - a new personal.narc + // beside the TM table that was meant to change with it. Everything is staged before + // anything is replaced, so a disk that fills up or a file someone has made read-only + // fails with the project still entirely the old version. + // + // arm9 is in the batch because TM/HM move reassignments are applied to the in-memory + // copy above; without writing it, reopening the project rebuilds arm9 from the unchanged + // file on disk and the edit silently disappears. + Tool.SaveBatch batch = saveBatch(); for (GameFiles gameFile : gameFiles) { - writeModifiedFile(gameFile.getPath()); + batch.file(gameFile.getPath()); } + batch.arm9().write(); if (gitEnabled) { commit(message); } + DataManager.markClean(dataClass); + DataManager.markClean(TextBankData.class); + JOptionPane.showMessageDialog(null, "Success! (If any error popups came before this message, then disregard).", "PokEditor", JOptionPane.INFORMATION_MESSAGE); } + public void markSheetDirty(Class dataClass) + { + DataManager.markDirty(dataClass); + DataManager.markDirty(TextBankData.class); + } + public void resetData(Class dataClass) { DataManager.resetData(rom, dataClass); } + /** + * Tells every other sheet backed by the same name bank that a row was added or removed. + *

+ * Several sheets index the same text bank: Personal, TM compatibility, Evolutions and + * Learnsets all show the species names, across three different data classes. Adding or + * removing a row shifts that shared bank, so a sheet the user is not even looking at ends + * up displaying every name below the change against the wrong entry - and because the bank + * is marked dirty, the next save of any sheet writes it to the ROM. There is no error and + * nothing to see on the sheet being edited. + *

+ * Only the display is corrected here, and deliberately so: the underlying operation is + * still questionable, because these tables are positional and deleting a row renumbers + * every entry after it. Correcting the view is what stops a silent wrong-row edit; whether + * the row buttons should exist on species-indexed sheets at all is a separate decision. + * + * @param bank the name bank that changed + * @param source the sheet that changed it, which has already fired its own events + */ + public void nameBankRowsChanged(TextBankData bank, DefaultSheetPanel source) + { + if (bank == null) + return; + + for (DefaultSheetPanel sheetPanel : sheetPanels()) + { + if (sheetPanel == source) + continue; + + // identity, not equality: the point is that these sheets hold the very same object + if (sheetPanel.getTable().getFormatModel().getNameTextBank() != bank) + continue; + + // the names are read through the frozen column model, and a full structure change + // would discard the column widths and renderers the table configured at build time + sheetPanel.getTable().getFormatModel().fireTableDataChanged(); + if (sheetPanel.getFrozenColumns().getModel() instanceof javax.swing.table.AbstractTableModel frozen) + frozen.fireTableDataChanged(); + } + } + + /** Every sheet panel currently open, flattened out of the groups they are arranged in. */ + private List> sheetPanels() + { + List> found = new ArrayList<>(); + for (JPanel panel : panels) + { + if (panel instanceof DefaultSheetPanel sheetPanel) + { + found.add(sheetPanel); + } + else if (panel instanceof PanelGroup panelGroup) + { + for (JPanel groupPanel : panelGroup.getPanels()) + { + if (groupPanel instanceof DefaultSheetPanel sheetPanel) + found.add(sheetPanel); + } + } + } + return found; + } + public void resetAllIndexedCellRendererText() { for (JPanel panel : panels) @@ -303,24 +434,39 @@ public String getDescription() String path = selected.getAbsolutePath(); if (!path.endsWith(".csv")) path = path + ".csv"; - try + try (BufferedWriter writer = Files.newBufferedWriter(Path.of(path), StandardCharsets.UTF_8)) { - BufferedWriter writer = new BufferedWriter(new FileWriter(path)); for (String[] row : data) { - for (String s : row) - writer.write(s + ","); + String[] quoted = new String[row.length]; + for (int i = 0; i < row.length; i++) + quoted[i] = quoteCsvField(row[i]); + writer.write(String.join(",", quoted)); writer.write("\n"); } - - writer.close(); } catch(IOException e) { + JOptionPane.showMessageDialog(null, "A fatal error occurred while writing the sheet to disk. See command-line for details.", "Error", JOptionPane.ERROR_MESSAGE); throw new RuntimeException(e); } } } + /** + * Quotes a single CSV field as per RFC 4180 - fields containing a comma, a double quote, + * or a line break are wrapped in double quotes with any embedded quotes doubled. + */ + private static String quoteCsvField(String field) + { + if (field == null) + return ""; + + if (field.indexOf(',') >= 0 || field.indexOf('"') >= 0 || field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) + return '"' + field.replace("\"", "\"\"") + '"'; + + return field; + } + private static JFileChooser prepareImageChooser(String title, boolean allowPalette) { String lastPath = Tool.preferences.get("pokeditor.imagePath", null); @@ -419,8 +565,7 @@ public List getPanels() @Override public boolean hasUnsavedChanges() { - //todo - return false; + return DataManager.hasUnsavedChanges(); } @Override diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditor.java index 66fbc2c..096e0dd 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditor.java @@ -13,12 +13,28 @@ public abstract class DefaultDataEditor model; private int selectedIndex; + private DefaultDataEditorPanel panel; + public DefaultDataEditor(EditorDataModel model) { this.model = model; selectedIndex = -1; } + void setPanel(DefaultDataEditorPanel panel) + { + this.panel = panel; + } + + /** + * @return the panel hosting this editor (which owns the entry selector), or null if this + * editor has not been added to one + */ + protected DefaultDataEditorPanel getPanel() + { + return panel; + } + public EditorDataModel getModel() { return model; @@ -55,13 +71,33 @@ public void deleteCurrentEntry() public BytesDataContainer writeSelectedEntryForCopy() { + // -1 is the "nothing selected yet" sentinel, and the toolbar's copy button is live from + // the moment the editor opens. Feeding it to get() raised IndexOutOfBoundsException at + // the user; "there is nothing to copy" is the same answer this method already gives for + // a model it cannot read. + if (!hasSelection()) + return null; + if (getModel() instanceof FormatModel formatModel) return formatModel.getData().get(selectedIndex).save(); return null; } + /** + * @return whether an entry is selected and is within the model's bounds + */ + private boolean hasSelection() + { + return selectedIndex >= 0 && getModel() != null && selectedIndex < getModel().getEntryCount(); + } + public void applyCopiedEntry(BytesDataContainer bytesDataContainer) { + // see writeSelectedEntryForCopy: with nothing selected there is nowhere to paste, and + // get(-1) threw from inside a catch that then tried to raise a dialog about it + if (!hasSelection() || bytesDataContainer == null) + return; + try { if (getModel() instanceof FormatModel formatModel) formatModel.getData().get(selectedIndex).setData(bytesDataContainer); diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditorPanel.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditorPanel.java index 5cc6789..acb1e8a 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditorPanel.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/DefaultDataEditorPanel.java @@ -30,6 +30,9 @@ public class DefaultDataEditorPanel private BytesDataContainer copiedEntry; + /** guards against the selector's own action listener re-entering the editor */ + private boolean suppressSelectionEvents; + public DefaultDataEditorPanel(PokeditorManager manager, DefaultDataEditor editor) { initComponents(); this.manager = manager; @@ -72,7 +75,62 @@ public DefaultDataEditorPanel(PokeditorManager manager, DefaultDataEditor if (!enabledToolbarButtons.contains(DataEditorButtons.IMPORT_FILE)) { toolBar1.remove(importFileButton); - toolBar1.remove(separator3); + toolBar1.remove(separator8); // separator3 belongs to addButton + } + + editor.setPanel(this); + } + + /** + * Adds the newly created entry to the selector and selects it - without this a new entry + * is written to the NARC but is unreachable, because the combo box is only ever populated + * in this class' constructor. + */ + public void entryAdded(int index) + { + EditorDataModel model = editor.getModel(); + entrySelectorComboBox.addItem(new ComboBoxItem(model.getEntryName(index))); + setSelectedEntryIndex(entrySelectorComboBox.getItemCount() - 1); + } + + /** + * Removes the provided entry from the selector and selects a neighbouring one. + */ + public void entryRemoved(int index) + { + if (index < 0 || index >= entrySelectorComboBox.getItemCount()) + return; + + suppressSelectionEvents = true; + try { + entrySelectorComboBox.removeItemAt(index); + } + finally { + suppressSelectionEvents = false; + } + + if (entrySelectorComboBox.getItemCount() == 0) + return; + + setSelectedEntryIndex(Math.min(index, entrySelectorComboBox.getItemCount() - 1)); + } + + /** + * Selects an entry WITHOUT notifying the editor - used both to restore the previous + * selection when the user cancels out of a switch, and after add/remove, where the editor + * drives the reload itself. + */ + public void setSelectedEntryIndex(int index) + { + if (index < 0 || index >= entrySelectorComboBox.getItemCount()) + return; + + suppressSelectionEvents = true; + try { + entrySelectorComboBox.setSelectedIndex(index); + } + finally { + suppressSelectionEvents = false; } } @@ -89,6 +147,9 @@ private void setIcons() } private void selectedEntryChanged(ActionEvent e) { + if (suppressSelectionEvents) + return; + editor.selectedIndexedChanged(entrySelectorComboBox.getSelectedIndex(), e); } @@ -126,16 +187,23 @@ private void pasteEntryButtonPressed(ActionEvent e) { */ private void exportFileButtonPressed(ActionEvent e) { BytesDataContainer container = editor.writeSelectedEntryForCopy(); + if (container == null) + return; + for (Map entry : container.values()) { for (byte[] data : entry.values()) { try { - FileUtils.promptLocationAndWriteFile(this, "exportFile", data, "Script File", ".scr"); + // A null return means the user cancelled the chooser or declined an + // overwrite. Nothing was written, so there is nothing to report. + if (FileUtils.promptLocationAndWriteFile(this, "exportFile", data, "Script File", ".scr") == null) + return; JOptionPane.showMessageDialog(this, "Success!", "Success", JOptionPane.INFORMATION_MESSAGE); } catch(IOException ex) { - throw new RuntimeException(ex); + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, "The file could not be written:\n" + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); } return; } @@ -153,11 +221,20 @@ private void importFileButtonPressed(ActionEvent e) { data = FileUtils.promptLocationAndReadFile(this, "exportFile", "Script File", ".scr"); } catch(IOException ex) { - throw new RuntimeException(ex); + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, "The file could not be read:\n" + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + return; } + if (data == null) // the user cancelled the file chooser + return; + + BytesDataContainer existing = editor.writeSelectedEntryForCopy(); + if (existing == null) + return; + BytesDataContainer container = new BytesDataContainer(); - container.insert(editor.writeSelectedEntryForCopy().keySet().toArray(GameFiles[]::new)[0], null, data); + container.insert(existing.keySet().toArray(GameFiles[]::new)[0], null, data); editor.applyCopiedEntry(container); selectedEntryChanged(null); } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/pokemon_sprite/PokemonSpriteEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/pokemon_sprite/PokemonSpriteEditor.java index 3c314d1..ba32dfb 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/pokemon_sprite/PokemonSpriteEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/pokemon_sprite/PokemonSpriteEditor.java @@ -16,6 +16,7 @@ import io.github.turtleisaac.nds4j.framework.GenericNtrFile; import io.github.turtleisaac.nds4j.images.IndexedImage; import io.github.turtleisaac.nds4j.images.Palette; +import io.github.turtleisaac.pokeditor.DataManager; import io.github.turtleisaac.pokeditor.formats.pokemon_sprites.PokemonSpriteData; import io.github.turtleisaac.pokeditor.formats.text.TextBankData; import io.github.turtleisaac.pokeditor.gamedata.TextFiles; @@ -75,7 +76,10 @@ public void selectedIndexedChanged(int idx, ActionEvent e) maleFrontYSpinner.setValue(model.getValueFor(idx, SpriteContents.MALE_FRONT_Y)); movementSpinner.setValue(model.getValueFor(idx, SpriteContents.MOVEMENT)); shadowXSpinner.setValue(model.getValueFor(idx, SpriteContents.SHADOW_X)); - shadowSizeComboBox.setSelectedIndex((Integer) model.getValueFor(idx, SpriteContents.SHADOW_SIZE)); + // shadowSize is read as a full u8, but this combo only has four entries - anything + // larger used to throw and leave the editor half-populated + int shadowSize = (Integer) model.getValueFor(idx, SpriteContents.SHADOW_SIZE); + shadowSizeComboBox.setSelectedIndex(Math.max(0, Math.min(shadowSize, shadowSizeComboBox.getItemCount() - 1))); int paletteIdx = (int) model.getValueFor(idx, SpriteContents.PARTY_ICON_PALETTE); partyIconPaletteSpinner.setValue(paletteIdx); @@ -191,6 +195,29 @@ private void shadowSizeChanged(ActionEvent e) { private void importSprite(PokemonSpriteDisplayPanel panel) { + try { + doImportSprite(panel); + } + catch (RuntimeException e) { + e.printStackTrace(); + JOptionPane.showMessageDialog(this, + "The sprite could not be imported:\n" + e.getMessage() + "\n\nSee the command-line for details.", + "Import Error", JOptionPane.ERROR_MESSAGE); + } + } + + private void doImportSprite(PokemonSpriteDisplayPanel panel) + { + if (!panel.hasImage()) + { + // the placeholder standing in for an empty slot has the wrong dimensions and no + // scan mode, so writing into it produces an all-black sprite in the ROM + JOptionPane.showMessageDialog(this, + "This species has no sprite in this slot, so there is nothing to import into.\nAction has been aborted.", + "No Sprite", JOptionPane.ERROR_MESSAGE); + return; + } + GenericNtrFile result = PokeditorManager.readIndexedImage(panel.contents != SpriteContents.PARTY_ICON); if (result == null) { JOptionPane.showMessageDialog(this, "An error occurred while reading the provided file.\nAction has been aborted."); @@ -210,6 +237,8 @@ private void importSprite(PokemonSpriteDisplayPanel panel) return; } + boolean isBattleSprite = panel.buttonStyle == PokemonSpriteDisplayPanel.HORIZONTAL; + if (panel.panel.image != null && !image.getPalette().equals(Palette.defaultPalette)) { if (image.getPalette().getColors().length > MAXIMUM_PALETTE_SIZE) { JOptionPane.showMessageDialog(this, @@ -219,7 +248,7 @@ private void importSprite(PokemonSpriteDisplayPanel panel) return; } - if (panel.buttonStyle == PokemonSpriteDisplayPanel.HORIZONTAL && !image.getPalette().equals(panel.panel.image.getPalette())) { + if (isBattleSprite && !image.getPalette().equals(panel.panel.image.getPalette())) { int confirmResult = JOptionPane.showConfirmDialog(this, "The provided image has a palette which differs from that of the current image. Would you like to overwrite?", "Palette Conflict", JOptionPane.YES_NO_CANCEL_OPTION); switch (confirmResult) { @@ -234,7 +263,9 @@ private void importSprite(PokemonSpriteDisplayPanel panel) } } - if (!image.getPalette().equals(Palette.defaultPalette)) + // the party icon has its own palette in a completely different file, so importing + // one must never write over the battle sprite palette + if (isBattleSprite && !image.getPalette().equals(Palette.defaultPalette)) { if (tabbedPane1.getSelectedComponent().equals(palettePanel)) { @@ -595,6 +626,9 @@ class PokemonSpriteDisplayPanel extends JPanel private final int buttonStyle; + /** false while this slot is showing the empty-slot placeholder rather than a real sprite */ + private boolean hasImage; + private SpriteContents contents; public PokemonSpriteDisplayPanel() @@ -687,10 +721,15 @@ public void actionPerformed(ActionEvent e) public void setImage(IndexedImage image) { if (image == null) { - setImage(new IndexedImage(80, 160, 4, Palette.defaultPalette)); + // most species have no female sprites - the placeholder below only exists so + // something can be painted, it is NOT a real sprite and must never be saved + hasImage = false; + panel.setImage(new IndexedImage(80, 160, 4, Palette.defaultPalette)); + setMaximumSize(getPreferredSize()); setEnabled(false); } else { + hasImage = true; setEnabled(true); panel.setImage(image); setMaximumSize(getPreferredSize()); @@ -698,6 +737,14 @@ public void setImage(IndexedImage image) repaint(); } + /** + * @return whether this slot holds a real sprite (as opposed to the empty-slot placeholder) + */ + public boolean hasImage() + { + return hasImage; + } + public void setPalette(Palette palette) { if (panel.image != null) @@ -714,6 +761,10 @@ public void setEnabled(boolean enabled) // super.setEnabled(enabled); exportButton.setEnabled(enabled); swapButton.setEnabled(enabled); + // the placeholder for an empty slot has the wrong dimensions and no scan mode, so + // importing into it can only produce a corrupt sprite - keep Import consistent with + // importSprite(), which refuses empty slots + importButton.setEnabled(enabled); } public void setContents(SpriteContents contents) @@ -763,6 +814,9 @@ class BattleMockupPanel extends JPanel static Image mediumShadow = new ImageIcon(BattleMockupPanel.class.getResource("/pokeditor/icons/shadow_medium.png")).getImage(); static Image largeShadow = new ImageIcon(BattleMockupPanel.class.getResource("/pokeditor/icons/shadow_large.png")).getImage(); + /** set once a paint has failed, so the failure is not reported over and over */ + private boolean paintFailed; + public BattleMockupPanel() { super(); @@ -775,8 +829,13 @@ public BattleMockupPanel() } @Override - public void paint(Graphics g) + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + if (paintFailed) + return; + try { //The origin (top left) is 0, 0 @@ -818,12 +877,18 @@ else if(shadowSize == ShadowSize.LARGE) back = maleBackPanel.panel.image; } - int startCoordinateX = toggleFrameButton.isSelected() ? spriteSize : 0; + int requestedX = toggleFrameButton.isSelected() ? spriteSize : 0; if (front != null) + { + int startCoordinateX = Math.max(0, Math.min(requestedX, front.getWidth() - spriteSize)); frontSprite = front.getSubImage(startCoordinateX, 0, spriteSize, spriteSize).getTransparentImage(); + } if (back != null) + { + int startCoordinateX = Math.max(0, Math.min(requestedX, back.getWidth() - spriteSize)); backSprite = back.getSubImage(startCoordinateX, 0, spriteSize, spriteSize).getTransparentImage(); + } int frontModifier = (int) (isFemale ? femaleFrontYSpinner.getValue() : maleFrontYSpinner.getValue()); int backModifier = (int) (isFemale ? femaleBackYSpinner.getValue() : maleBackYSpinner.getValue()); @@ -845,10 +910,18 @@ else if(shadowSize == ShadowSize.LARGE) g.drawImage(image, 0, 0, getWidth(), getHeight(), null); } - catch(IndexedImage.ImageException e) + catch(IndexedImage.ImageException | RuntimeException e) { + // NEVER open a modal dialog from a paint method - dismissing it repaints, which + // re-enters here, throws again and reopens the dialog forever. Draw a placeholder + // and latch the failure instead. e.printStackTrace(); - JOptionPane.showMessageDialog(this, "A fatal image-parsing error has occurred while attempting to display the in-battle sprite preview. Check the command-line for details.", "Error", JOptionPane.ERROR_MESSAGE); + paintFailed = true; + + g.setColor(Color.DARK_GRAY); + g.fillRect(0, 0, getWidth(), getHeight()); + g.setColor(Color.WHITE); + g.drawString("Battle preview unavailable - see command-line", 8, getHeight() / 2); } } @@ -905,8 +978,9 @@ public PalettePanel() add(button, String.format("cell %d %d", col, row)); } } - add(new JButton("Set to Shiny"), "cell 0 4 4 1"); - add(new JButton("Swap with Shiny"), "cell 0 4 4 1"); + // NOTE: the "Set to Shiny" and "Swap with Shiny" buttons which used to be added here + // were constructed with no ActionListener at all - they looked functional and did + // nothing, so they have been removed rather than shipped as dead controls for (JButton button : buttons) { button.addActionListener(this::colorChangeRequested); @@ -916,11 +990,15 @@ public PalettePanel() public void setPalette(Palette palette) { this.palette = palette; - int i = 0; - for (JButton button : buttons) + + // an optimal indexed PNG out of GIMP/Aseprite frequently has fewer than 16 colors, + // and reading past the end used to throw and leave this panel half-updated + int available = palette == null ? 0 : Math.min(buttons.length, palette.getNumColors()); + + for (int i = 0; i < buttons.length; i++) { - Color c = palette.getColor(i++); - button.setBackground(c); + buttons[i].setBackground(i < available ? palette.getColor(i) : null); + buttons[i].setEnabled(i < available); } } @@ -1113,6 +1191,7 @@ public Object getValueFor(int entryIdx, SpriteContents property) public void setValueFor(Object aValue, int entryIdx, SpriteContents property) { PokemonSpriteData entry = getData().get(entryIdx); + DataManager.markDirty(PokemonSpriteData.class); switch (property) { case FEMALE_BACK -> entry.setFemaleBack((IndexedImage) aValue); diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocument.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocument.java index 319b6fd..f3742c7 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocument.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocument.java @@ -30,16 +30,51 @@ public class ScriptDocument extends DefaultStyledDocument private List variableList; + /** + * Re-lexing and re-parsing the whole file with ANTLR (and issuing one setCharacterAttributes + * per token) is far too expensive to do synchronously on every keystroke, so the work is + * coalesced behind this timer. + */ + private final Timer syntaxTimer; + + private static final int SYNTAX_UPDATE_DELAY_MS = 250; + public ScriptDocument(ScriptPane pane) { super(StyleContext.getDefaultStyleContext()); context = (StyleContext) getAttributeContext(); scriptElementList = new ScriptElementList(); addStylesToDocument(this); - pane.insertComponent(new JButton("test")); + + syntaxTimer = new Timer(SYNTAX_UPDATE_DELAY_MS, e -> { + try { + setSyntaxAttributes(); + updateLineNumbers(); + } + catch(BadLocationException ex) { + ex.printStackTrace(); + } + catch(RuntimeException ex) { + // a syntax highlighter must never take the editor down over bad syntax, and + // half-finished text is bad syntax by definition - it is what typing looks like. + // ANTLR's error recovery can match a rule against zero tokens (the 14 characters + // "script(script(" are enough), which produces a zero width ElementRange and an + // unchecked throw. That escaped this handler and reached the EDT, where a user + // running a double-clicked jar would never even see the stack trace. + ex.printStackTrace(); + } + }); + syntaxTimer.setRepeats(false); + setLineNumberPane(pane.getLineNumberPane()); } + private void scheduleSyntaxUpdate() + { + if (syntaxTimer != null) + syntaxTimer.restart(); + } + public void setVariableList(List variableList) { this.variableList = variableList; @@ -49,21 +84,84 @@ public FieldScriptData getScriptData() throws BadLocationException, ScriptDataPr { ScriptDataProducer visitor = new ScriptDataProducer(); - return visitor.produceScriptData(getText(0, getLength())); + return visitor.produceScriptData(replaceVariableNamesWithNumbers(getText(0, getLength()))); + } + + /** + * Variable names are a display-only convenience - the compiler's parameter resolver only + * understands the raw values, so they have to be turned back into their hex IDs before the + * text is compiled. + */ + public String replaceVariableNamesWithNumbers(String text) + { + if (variableList == null || variableList.isEmpty()) + return text; + + String result = text; + for (ScriptVariable variable : variableList) + { + String name = variable.getVariableName(); + if (name == null || name.isBlank()) + continue; + + result = result.replaceAll("\\b" + Pattern.quote(name) + "\\b", + Matcher.quoteReplacement("0x" + Integer.toHexString(variable.getVariableID()))); + } + + return result; + } + + /** + * The inverse of {@link #replaceVariableNamesWithNumbers(String)} - applied when building + * the text shown to the user, so the shared model keeps holding Integers. + */ + public String replaceVariableNumbersWithNames(String text) + { + if (variableList == null || variableList.isEmpty()) + return text; + + String result = text; + for (ScriptVariable variable : variableList) + { + String name = variable.getVariableName(); + if (name == null || name.isBlank()) + continue; + + String hex = Integer.toHexString(variable.getVariableID()); + result = result.replaceAll("\\b0[xX]0*" + hex + "\\b", Matcher.quoteReplacement(name)); + } + + return result; } @Override public void insertString(int offs, String str, AttributeSet a) throws BadLocationException { super.insertString(offs, str, a); - setSyntaxAttributes(); + // the element ranges describe the text as it was before this edit, and the re-highlight + // is debounced by a quarter of a second. leaving them in place means the pane answers + // hovers and ctrl-clicks from offsets that no longer exist - after a delete, find(0) + // could hand back a 13 character range over a 2 character document, and reading its + // text threw straight onto the EDT. no ranges at all is the honest answer until the + // visitor has run again; every caller of find() already handles null. + // + // only when the text actually moved, though: an insert of nothing leaves every offset + // valid, and discarding the ranges anyway would blank the tooltips and ctrl-click + // targets for a quarter of a second in response to an edit that changed nothing. + if (str != null && !str.isEmpty()) + scriptElementList.clear(); + scheduleSyntaxUpdate(); } @Override public void remove(int offs, int len) throws BadLocationException { super.remove(offs, len); - setSyntaxAttributes(); + // see insertString: stale ranges outlive the text they describe for the debounce window, + // but a removal of zero characters moves nothing and must leave them alone + if (len > 0) + scriptElementList.clear(); + scheduleSyntaxUpdate(); } protected void setSyntaxAttributes() throws BadLocationException @@ -141,52 +239,34 @@ public void setLineNumberPane(JTextPane lineNumberPane) StyleConstants.setFontSize(def, FONT_SIZE); StyleConstants.setFontFamily(def, "Monospaced"); - Document numberDoc = lineNumberPane.getDocument(); - - try{ - for (int i = 0; i < 2000; i++) - { - numberDoc.insertString(numberDoc.getLength(), i + "\n", null); - } - } - catch (BadLocationException e) { - throw new RuntimeException(e); - } - + updateLineNumbers(); } } - public void refactorString(String oldName, String newName) + /** + * Rebuilds the line number gutter so it matches this document's actual line count. + * Script lines are numbered from 1 (they used to start at 0, and the gutter was hardcoded + * to 2000 entries regardless of the file). + */ + private void updateLineNumbers() { + if (lineNumberPane == null) + return; + + int lineCount = Math.max(1, getDefaultRootElement().getElementCount()); + + StringBuilder builder = new StringBuilder(); + for (int i = 1; i <= lineCount; i++) + { + builder.append(i).append("\n"); + } + + Document numberDoc = lineNumberPane.getDocument(); try { - String text = getText(0, getLength()); -// int idx; -// while ((idx = text.indexOf(oldName)) != -1) -// { -// char after = text.charAt(idx + 1); -// if (after == '\n' || after == '\r' || after == '\t' || after == ' ') -// { -// if (idx != 0) -// { -// char before = text.charAt(idx-1); -// if (before != '\n' && after != '\r' && before != '\t' && before != ' ') -// continue; -// } -// text = text.replaceFirst(oldName, newName); -// } -// } - - Pattern pattern = Pattern.compile(String.format("\\\\b%s\\\\b", oldName)); - Matcher matcher = pattern.matcher(text); - if (matcher.find()) { - System.currentTimeMillis(); -// parameterToValueMap.put(matcher.group().substring(1), ret); - } - -// replace(); -// text.replaceAll("",""); - } - catch(BadLocationException e) { + numberDoc.remove(0, numberDoc.getLength()); + numberDoc.insertString(0, builder.toString(), null); + } + catch (BadLocationException e) { throw new RuntimeException(e); } } @@ -222,6 +302,34 @@ public boolean wasSuccessful() return !invalid; } + /** the arguments of one candidate range, so the guard below sees them before construction */ + private record RangeSpec(int min, int maxExclusive, String toolTipText, ElementType elementType) + { + RangeSpec(int min, int maxExclusive, String toolTipText) + { + this(min, maxExclusive, toolTipText, null); + } + } + + /** + * Records a range, unless it covers no characters. + *

+ * ANTLR's error recovery can match a rule against zero tokens while the file is being + * typed - "script(script(" is enough - and the context it hands back then reports a stop + * index one before its start. ElementRange rightly refuses to be empty, but that refusal + * is an unchecked throw from inside the highlighter, so half-typed text used to take the + * whole visitor down. There is nothing to highlight in zero characters, so skip it. + */ + private void addRange(RangeSpec spec) + { + if (spec.maxExclusive() <= spec.min()) + return; + + scriptElementList.add(spec.elementType() == null + ? new ElementRange(spec.min(), spec.maxExclusive(), spec.toolTipText()) + : new ElementRange(spec.min(), spec.maxExclusive(), spec.toolTipText(), spec.elementType())); + } + @Override public Void visitScript_file(ScriptFileParser.Script_fileContext ctx) { @@ -250,12 +358,12 @@ public Void visitLabel(ScriptFileParser.LabelContext ctx) if (!(ctx.parent instanceof ScriptFileParser.Label_definitionContext)) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, null, ElementType.LABEL)); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, null, ElementType.LABEL)); setCharacterAttributes(ctx.start.getStartIndex(), len, getStyle(LABEL), true); } else if (labelNames.contains(ctx.getText())) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, "A label with this name already exists")); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, "A label with this name already exists")); setCharacterAttributes(ctx.start.getStartIndex(), len, getStyle(INCORRECT), true); this.invalid = true; } @@ -316,12 +424,12 @@ public Void visitAction(ScriptFileParser.ActionContext ctx) if (!(ctx.parent instanceof ScriptFileParser.Action_definitionContext)) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, null, ElementType.LABEL)); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, null, ElementType.LABEL)); setCharacterAttributes(ctx.start.getStartIndex(), len, getStyle(ACTION_OR_TABLE_LABEL), true); } else if (actionNames.contains(ctx.getText())) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, "An action with this name already exists")); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, "An action with this name already exists")); setCharacterAttributes(ctx.start.getStartIndex(), len, getStyle(INCORRECT), true); this.invalid = true; } @@ -349,7 +457,7 @@ public Void visitScript_definition(ScriptFileParser.Script_definitionContext ctx { if (terminalNode.symbol.getStartIndex() == -1) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, "You are missing a script number here")); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, "You are missing a script number here")); invalid = true; this.invalid = true; } @@ -358,13 +466,13 @@ public Void visitScript_definition(ScriptFileParser.Script_definitionContext ctx int scriptID = Integer.parseInt(terminalNode.getText()); if (scriptID == 0) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, "You can't use index 0 for a script")); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, "You can't use index 0 for a script")); invalid = true; this.invalid = true; } else if (scriptNumbers.contains(scriptID)) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, "This script ID number is already in use")); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, "This script ID number is already in use")); invalid = true; this.invalid = true; } @@ -443,7 +551,7 @@ public Void visitCommand(ScriptFileParser.CommandContext ctx) if (paramCount == actualCount) { setCharacterAttributes(ctx.start.getStartIndex(), len, getStyle(COMMAND), true); - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, commandMacro.toString(), ElementType.COMMAND)); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, commandMacro.toString(), ElementType.COMMAND)); return super.visitCommand(ctx); } else @@ -463,7 +571,7 @@ public Void visitCommand(ScriptFileParser.CommandContext ctx) toolTipText = text.toString(); setCharacterAttributes(ctx.start.getStartIndex(), len, getStyle(INCORRECT), true); - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, toolTipText, ElementType.COMMAND)); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, toolTipText, ElementType.COMMAND)); return null; } } @@ -474,7 +582,7 @@ public Void visitCommand(ScriptFileParser.CommandContext ctx) this.invalid = true; } - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, toolTipText)); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, toolTipText)); return null; } @@ -501,7 +609,7 @@ public Void visitParameter(ScriptFileParser.ParameterContext ctx) { if (variable.getVariableName().equalsIgnoreCase(ctx.getText().trim())) { - scriptElementList.add(new ElementRange(ctx.start.getStartIndex(), stopExclusive, "0x" + Integer.toHexString(variable.getVariableID()).toUpperCase())); + addRange(new RangeSpec(ctx.start.getStartIndex(), stopExclusive, "0x" + Integer.toHexString(variable.getVariableID()).toUpperCase())); break; } } @@ -576,7 +684,7 @@ public int getLength() public boolean contains(int value) { - return value >= min && value < maxExclusive - 1; + return value >= min && value < maxExclusive; } public boolean contains(ElementRange range) @@ -608,7 +716,11 @@ public int getMaxExclusive() public String toString() { try { - return getText(min, maxExclusive - min + 1); + // the range is half open, so its length is exactly maxExclusive - min. the + // extra +1 read one character too many: it never returned the range's own + // text, and at the end of the document it returned the implicit trailing + // newline instead of failing, which is why it went unnoticed. + return getText(min, maxExclusive - min); } catch(BadLocationException e) { throw new RuntimeException(e); @@ -622,20 +734,30 @@ public static class ScriptElementList public void add(ElementRange newRange) { - boolean found = false; - for (int i = 0; i < elementRanges.size(); i++) + // a range nested inside a wider one goes to the front, so that find(offset) - which + // scans in order and returns the first match - answers with the innermost element + // rather than whichever enclosing one happens to come first. + // + // the insertion has to happen once, not once per enclosing range. it used to sit + // inside the loop, so a range nested two deep was stored twice and one nested three + // deep three times; the list grew with nesting depth and find() could return a + // duplicate. today the visitor only nests two levels, which is why this stayed + // invisible, but the list is public API. + boolean nested = false; + for (ElementRange existingRange : elementRanges) { - ElementRange existingRange = elementRanges.get(i); if (existingRange.contains(newRange) && existingRange.getLength() != newRange.getLength()) { -// elementRanges.remove(existingRange); - elementRanges.add(0, newRange); - i++; - found = true; + nested = true; + break; } } - if (!found) + if (nested) + { + elementRanges.add(0, newRange); + } + else { elementRanges.add(newRange); } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPane.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPane.java index 6402a13..cea255c 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPane.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPane.java @@ -23,21 +23,19 @@ public void keyPressed(KeyEvent e) { if (e.isMetaDown() || e.isControlDown()) { - int offset = viewToModel2D(getMousePosition()); - highlight(offset); - } - } + // null whenever the pointer is not over this component, which used to throw + // straight out of processKeyEvent and silently break Ctrl+C/V/A + Point mousePosition = getMousePosition(); + if (mousePosition == null) + return; - @Override - public void keyReleased(KeyEvent e) - { - try { - scriptDocument.setSyntaxAttributes(); - } - catch(BadLocationException ex) { - throw new RuntimeException(ex); + highlight(viewToModel2D(mousePosition)); } } + + // NOTE: there is deliberately no keyReleased re-parse here - the document already + // re-highlights itself whenever it is mutated, so re-lexing the whole file again on + // every key release (including arrow keys and bare modifiers) only made typing stutter }); } @@ -83,6 +81,9 @@ public String getToolTipText(MouseEvent event) private void highlight(int offset) { + if (scriptDocument == null) + return; + ScriptDocument.ElementRange elementRange = scriptDocument.getScriptElementList().find(offset); if (elementRange != null && elementRange.getElementType() == ScriptDocument.ElementType.LABEL) { @@ -94,7 +95,7 @@ private void highlight(int offset) throw new RuntimeException(ex); } - scriptDocument.setCharacterAttributes(elementRange.getMin(), elementRange.getMaxExclusive() - elementRange.getMin() + 1, scriptDocument.getStyle(ScriptDocument.GOTO_LABEL), true); + scriptDocument.setCharacterAttributes(elementRange.getMin(), elementRange.getMaxExclusive() - elementRange.getMin(), scriptDocument.getStyle(ScriptDocument.GOTO_LABEL), true); } } @@ -108,7 +109,7 @@ protected void processMouseEvent(MouseEvent e) getHighlighter().removeAllHighlights(); } - if ((modifiers & (MouseEvent.CTRL_DOWN_MASK | MouseEvent.META_DOWN_MASK)) != 0 && (modifiers & MouseEvent.SHIFT_DOWN_MASK) == 0) + if (scriptDocument != null && (modifiers & (MouseEvent.CTRL_DOWN_MASK | MouseEvent.META_DOWN_MASK)) != 0 && (modifiers & MouseEvent.SHIFT_DOWN_MASK) == 0) { int offset = viewToModel2D(e.getPoint()); @@ -131,6 +132,13 @@ protected void processMouseEvent(MouseEvent e) String labelName = text.substring(elementRange.getMin(), elementRange.getMaxExclusive()); int definitionOffset = text.indexOf(labelName + ":"); + if (definitionOffset < 0) // the label is undefined (or has been renamed) + { + Toolkit.getDefaultToolkit().beep(); + super.processMouseEvent(e); + return; + } + // JPopupMenu popupMenu = new JPopupMenu(); // popupMenu.setPopupSize(500, 500); // popupMenu.show(this, 0, e.getY()); diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/field/FieldScriptEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/field/FieldScriptEditor.java index 3481534..3cefe9b 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/field/FieldScriptEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/field/FieldScriptEditor.java @@ -15,6 +15,7 @@ import io.github.turtleisaac.nds4j.ui.*; import io.github.turtleisaac.nds4j.ui.ThemeUtils; +import io.github.turtleisaac.pokeditor.DataManager; import io.github.turtleisaac.pokeditor.formats.GenericFileData; import io.github.turtleisaac.pokeditor.formats.scripts.GenericScriptData; import io.github.turtleisaac.pokeditor.formats.scripts.FieldScriptData; @@ -61,8 +62,8 @@ public FieldScriptEditor(List data, List textBa editMode = false; initComponents(); // FieldScriptEditorKit editorKit = new FieldScriptEditorKit(); - StyledDocument document = new ScriptDocument(textPane1); - textPane1.setDocument(document); + ScriptDocument document = new ScriptDocument(textPane1); + textPane1.setScriptDocument(document); // NOT setDocument - that leaves scriptDocument null textPane1.setBackground(new Color(58, 56, 77)); textPane1.setScrollPane(scrollPane1); textPane1.setForeground(Color.WHITE); @@ -74,10 +75,10 @@ public FieldScriptEditor(List data, List textBa levelScriptList.setSelectedIndex(-1); levelScriptListValueChanged(null); clearInputFields(); -// valueField.addChangeListener(e -> paramFieldTextChange()); -// scriptNoField.addChangeListener(e -> paramFieldTextChange()); -// variableField.addChangeListener(e -> paramFieldTextChange()); - removeButton.setEnabled(false); + valueField.addChangeListener(e -> paramFieldTextChange()); + scriptNoField.addChangeListener(e -> paramFieldTextChange()); + variableField.addChangeListener(e -> paramFieldTextChange()); + paddingCheckbox.addActionListener(e -> commitLevelScriptChanges()); try { @@ -94,7 +95,40 @@ public FieldScriptEditor(List data, List textBa setIcons(); setupVariableTracker(); setupFlagTracker(); - replaceVariableNumbersWithNames(); + + textPane1.getScriptDocument().setVariableList(variableTracker.getVariableList()); + installDocumentDirtyListener(textPane1.getScriptDocument()); + + // establishes the non-edit-mode button states - addButton is declared disabled by the + // generated code and nothing else ever turned it back on, which made the level script + // editor completely read-only + editMode = false; + toggleEditModeStates(); + levelScriptListValueChanged(null); + } + + /** + * Tracks whether the script text has been edited since it was last loaded or saved, so + * switching entries can offer to save instead of silently throwing the edits away. + */ + private boolean documentDirty; + + private void installDocumentDirtyListener(ScriptDocument document) + { + if (document == null) + return; + + document.addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent e) { documentDirty = true; } + + @Override + public void removeUpdate(DocumentEvent e) { documentDirty = true; } + + @Override + public void changedUpdate(DocumentEvent e) { /* attribute-only change (syntax highlighting) */ } + }); } private void setupVariableTracker() @@ -115,10 +149,26 @@ public void postUpdateVariableTableAction() public void actionPerformed(ActionEvent e) { ScriptVariable variable = variableTracker.getSelectedVariable(); + if (variable == null) + { + JOptionPane.showMessageDialog(variableTracker, "Select a variable to rename first.", "PokEditor", JOptionPane.INFORMATION_MESSAGE); + return; + } + String oldName = variable.getVariableName(); String newName = JOptionPane.showInputDialog(variableTracker, "Enter the new name for this variable"); - if (oldName.equalsIgnoreCase(newName)) + if (newName == null) // the user cancelled + return; + + newName = newName.trim(); + if (newName.isEmpty()) + { + JOptionPane.showMessageDialog(variableTracker, "A variable name cannot be blank.\nAction aborted.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + if (newName.equalsIgnoreCase(oldName)) { return; } @@ -132,32 +182,11 @@ public void actionPerformed(ActionEvent e) } } - for (GenericScriptData data : ((FormatModel) getModel()).getData()) - { - if (data instanceof FieldScriptData fieldScriptData) - { - for (GenericScriptData.ScriptComponent component : fieldScriptData) - { - if (component instanceof FieldScriptData.ScriptCommand scriptCommand) - { - Object[] parameters = scriptCommand.getParameters(); - if (parameters != null) - { - for (int i = 0; i < parameters.length; i++) - { - if (oldName.equals(parameters[i])) - { - parameters[i] = newName; - } - } - } - } - } - } - } - + // NOTE: the model only ever holds Integers - variable names exist purely in the + // displayed text - so a rename only needs the display refreshed variable.setVariableName(newName); variableTracker.fireTableDataChanged(); + replaceVariableNumbersWithNames(); } }); @@ -188,52 +217,52 @@ private void setupFlagTracker() // flagTrackerFrame.setJMenuBar(variableTracker.getMenuBar()); } + /** + * Refreshes the DISPLAYED script text so variable IDs read as their friendly names. + *

+ * This deliberately does not touch the model: it used to walk every field script and swap + * Integer parameters >= 0x4000 for Strings, which the command writer's parameter resolver + * cannot understand - saving then threw "An invalid parameter was provided (NAME)" and + * aborted the entire ROM write, for every script rather than just the open one. The + * substitution now lives purely in the display path, and {@link ScriptDocument#getScriptData()} + * reverses it before the text is compiled. + */ private void replaceVariableNumbersWithNames() { - List variableList = variableTracker.getVariableList(); + ScriptDocument document = textPane1.getScriptDocument(); + if (document == null) + return; - Map variableIdToNameMap = new HashMap<>(); + document.setVariableList(variableTracker.getVariableList()); - for (ScriptVariable variable : variableList) - { - variableIdToNameMap.put(variable.getVariableID(),variable.getVariableName()); - } + if (documentDirty) // never clobber unsaved edits just to relabel variables + return; -// ScriptDocument doc = textPane1.getScriptDocument(); -// if (doc != null) -// { -// for (int i = 0x4000; i <= 0x800C; i++) -// { -// if (variableIdToNameMap.containsKey(i)) -// doc.refactorString("0x" + Integer.toHexString(i).toUpperCase(), variableIdToNameMap.get(i)); -// } -// } + int idx = getSelectedIndex(); + if (idx < 0) + return; - for (GenericScriptData data : ((FormatModel) getModel()).getData()) - { - if (data instanceof FieldScriptData fieldScriptData) - { - for (GenericScriptData.ScriptComponent component : fieldScriptData) - { - if (component instanceof FieldScriptData.ScriptCommand scriptCommand) - { - Object[] parameters = scriptCommand.getParameters(); - if (parameters != null) - { - for (int i = 0; i < parameters.length; i++) - { - if (parameters[i] instanceof Integer value && value >= 0x4000 && variableIdToNameMap.containsKey(value)) - { - parameters[i] = variableIdToNameMap.get(value); - } - } - } - } - } - } - } + Object entry = getModel().getValueFor(idx, null); + if (entry instanceof FieldScriptData scriptData) + loadScriptText(document, scriptData); } + /** + * Replaces the contents of the document with the provided script, applying variable names + * for display only. + */ + private void loadScriptText(ScriptDocument document, FieldScriptData scriptData) + { + try { + document.remove(0, document.getLength()); + document.insertString(0, document.replaceVariableNumbersWithNames(scriptData.toString()), document.getStyle("regular")); + } + catch (BadLocationException ble) { + System.err.println("Couldn't insert text into text pane."); + ble.printStackTrace(); + } + documentDirty = false; + } private void setIcons() { @@ -246,10 +275,25 @@ private void setIcons() @Override public void selectedIndexedChanged(int idx, ActionEvent e) { + int previousIndex = getSelectedIndex(); + + if (idx != previousIndex && !confirmDiscardUnsavedScript()) + { + // the user backed out - put the selector back where it was + if (getPanel() != null && previousIndex >= 0) + getPanel().setSelectedEntryIndex(previousIndex); + return; + } + super.selectedIndexedChanged(idx, e); + + if (idx < 0) + return; + EditorDataModel model = getModel(); GenericScriptData data = (GenericScriptData) model.getValueFor(idx, null); -// errorsList.removeAll(); + + errorsList.setModel(new DefaultListModel<>()); // stale errors from another script if (data instanceof FieldScriptData scriptData) { @@ -259,17 +303,14 @@ public void selectedIndexedChanged(int idx, ActionEvent e) ScriptDocument document = new ScriptDocument(textPane1); document.setVariableList(variableTracker.getVariableList()); textPane1.setScriptDocument(document); + installDocumentDirtyListener(document); resetDisplayedFieldScriptData(scriptData); - try { - document.insertString(0, scriptData.toString(), document.getStyle("regular")); - } catch (BadLocationException ble) { - System.err.println("Couldn't insert initial text into text pane."); - } + loadScriptText(document, scriptData); scrollPane1.getVerticalScrollBar().setValue(0); } - else if (data instanceof LevelScriptData) + else if (data instanceof LevelScriptData levelScriptData) { remove(fieldScriptPanel); add(levelScriptPanel, "cell 1 0"); @@ -277,12 +318,68 @@ else if (data instanceof LevelScriptData) levelScriptDataListModel = new DefaultListModel<>(); levelScriptDataListModel.addAll(data); levelScriptList.setModel(levelScriptDataListModel); + paddingCheckbox.setSelected(levelScriptData.isHasPadding()); + + editMode = false; + toggleEditModeStates(); + levelScriptListValueChanged(null); } updateUI(); + } + /** + * @return true if it is safe to throw away the current script text (either it is unchanged, + * or the user chose to save/discard it); false if the user cancelled + */ + private boolean confirmDiscardUnsavedScript() + { + if (!documentDirty) + return true; -// errorsList.setModel(listModel); + int result = JOptionPane.showConfirmDialog(this, + "This script has unsaved changes.\nWould you like to save them first?", + "Unsaved Changes", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE); + + switch (result) + { + case JOptionPane.YES_OPTION -> { + return saveScriptChanges(); + } + case JOptionPane.NO_OPTION -> { + documentDirty = false; + return true; + } + default -> { + return false; + } + } + } + + /** + * Rebuilds the selected level script from the on-screen trigger list and pushes it through + * the model - without this every Add/Remove/Confirm and the padding checkbox were purely + * decorative, because the list model is a throwaway copy. + */ + private void commitLevelScriptChanges() + { + int idx = getSelectedIndex(); + if (idx < 0) + return; + + EditorDataModel model = getModel(); + Object entry = model.getValueFor(idx, null); + if (!(entry instanceof LevelScriptData levelScriptData)) + return; + + levelScriptData.clear(); + for (int i = 0; i < levelScriptDataListModel.getSize(); i++) + { + levelScriptData.add(levelScriptDataListModel.get(i)); + } + levelScriptData.setHasPadding(paddingCheckbox.isSelected()); + + model.setValueFor(levelScriptData, idx, null); } @Override @@ -295,6 +392,9 @@ public void addNewEntry() Object selection = JOptionPane.showInputDialog(this, message, "PokEditor", JOptionPane.INFORMATION_MESSAGE, null, new Object[] {fieldScript, levelScript}, fieldScript); + if (selection == null) // the user cancelled + return; + EditorDataModel model = getModel(); if (model instanceof FormatModel formatModel) @@ -312,6 +412,11 @@ else if (selection.equals(levelScript)) else return; + // the selector is only populated in DefaultDataEditorPanel's constructor, so without + // this the new entry is written to the NARC but is unreachable + if (getPanel() != null) + getPanel().entryAdded(newIndex); + selectedIndexedChanged(newIndex, null); updateUI(); } @@ -321,7 +426,34 @@ else if (selection.equals(levelScript)) @Override public void deleteCurrentEntry() { + int idx = getSelectedIndex(); + if (idx < 0) + { + JOptionPane.showMessageDialog(this, "Select the entry you would like to delete first.", "PokEditor", JOptionPane.INFORMATION_MESSAGE); + return; + } + + if (!(getModel() instanceof FormatModel formatModel)) + return; + + List data = formatModel.getData(); + if (idx >= data.size()) + return; + if (JOptionPane.showConfirmDialog(this, + "Delete script entry " + idx + "?\nEvery script after it will shift down by one, which will break any\nscript reference which points at them.", + "PokEditor", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE) != JOptionPane.YES_OPTION) + return; + + documentDirty = false; + data.remove(idx); + + if (getPanel() != null) + getPanel().entryRemoved(idx); + + int neighbour = Math.min(idx, data.size() - 1); + selectedIndexedChanged(neighbour, null); + updateUI(); } private void resetDisplayedFieldScriptData(FieldScriptData scriptData) @@ -358,30 +490,46 @@ else if (component instanceof FieldScriptData.ActionLabel actionLabel) } private void saveScriptChangesButtonPressed(ActionEvent e) { - if (textPane1.getDocument() instanceof ScriptDocument scriptDocument) + saveScriptChanges(); + } + + /** + * @return true if the script compiled and was written back into the model + */ + private boolean saveScriptChanges() + { + if (!(textPane1.getDocument() instanceof ScriptDocument scriptDocument)) + return false; + + try { - try - { - FieldScriptData data = scriptDocument.getScriptData(); - JOptionPane.showMessageDialog(this, "Script file saved!", "Field Script Editor", JOptionPane.INFORMATION_MESSAGE); + FieldScriptData data = scriptDocument.getScriptData(); - EditorDataModel model = getModel(); - model.setValueFor(data, getSelectedIndex(), null); + EditorDataModel model = getModel(); + model.setValueFor(data, getSelectedIndex(), null); - resetDisplayedFieldScriptData(data); - } - catch(BadLocationException ex) { - throw new RuntimeException(ex); - } - catch(ScriptDataProducer.ScriptCompilationException ex) { - DefaultListModel errorListModel = new DefaultListModel<>(); - for (Throwable throwable : ex.getSuppressed()) - { - errorListModel.addElement(throwable.getMessage()); - System.err.println(throwable.getMessage()); - } - errorsList.setModel(errorListModel); + resetDisplayedFieldScriptData(data); + errorsList.setModel(new DefaultListModel<>()); + documentDirty = false; + + // only claim success once the write has actually happened + JOptionPane.showMessageDialog(this, "Script file saved!", "Field Script Editor", JOptionPane.INFORMATION_MESSAGE); + return true; + } + catch(BadLocationException ex) { + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, "The script could not be read from the editor:\n" + ex.getMessage(), "Field Script Editor", JOptionPane.ERROR_MESSAGE); + return false; + } + catch(ScriptDataProducer.ScriptCompilationException ex) { + DefaultListModel errorListModel = new DefaultListModel<>(); + for (Throwable throwable : ex.getSuppressed()) + { + errorListModel.addElement(throwable.getMessage()); + System.err.println(throwable.getMessage()); } + errorsList.setModel(errorListModel); + return false; } } @@ -467,6 +615,10 @@ else if (anyFieldEmpty()) } + /** + * Called from BOTH mousePressed and mouseReleased - isPopupTrigger() is only ever true on + * the release on Windows, which made the trigger context menu unreachable there. + */ private void levelScriptListMousePressed(MouseEvent e) { if (e.isPopupTrigger()) { JPopupMenu menu = new JPopupMenu(); @@ -548,10 +700,24 @@ private LevelScriptData.LevelScriptTrigger buildTriggerFromFields() { } private void confirmButtonActionPerformed(ActionEvent e) { + // capture the index BEFORE addTriggerToList() - clicking empty space below the last row + // clears the selection while edit mode stays on, and remove(-1) throws while leaving a + // duplicate trigger appended + int editedIndex = levelScriptList.getSelectedIndex(); + + if (editedIndex == -1) + { + JOptionPane.showMessageDialog(this, "The trigger being edited is no longer selected.\nAction has been aborted.", "Level Script Editor", JOptionPane.ERROR_MESSAGE); + clearInputFields(); + editMode = false; + toggleEditModeStates(); + return; + } + GenericScriptData.ScriptComponent built = addTriggerToList(); if (built != null) - levelScriptDataListModel.remove(levelScriptList.getSelectedIndex()); + levelScriptDataListModel.remove(editedIndex); int count = -1; for (LevelScriptData.LevelScriptTrigger lst : Arrays.stream(levelScriptDataListModel.toArray()).map(s -> (LevelScriptData.LevelScriptTrigger) s).toList()) { @@ -563,6 +729,7 @@ private void confirmButtonActionPerformed(ActionEvent e) { levelScriptList.setSelectedIndex(count + 1); editMode = false; toggleEditModeStates(); + commitLevelScriptChanges(); } private void clearInputFields() { @@ -602,12 +769,17 @@ private void toggleEditModeStates() } private void addButtonActionPerformed(ActionEvent e) { - addTriggerToList(); + if (addTriggerToList() != null) + commitLevelScriptChanges(); } private void removeButtonActionPerformed(ActionEvent e) { - if (levelScriptList.getSelectedIndex() != -1) - levelScriptDataListModel.remove(levelScriptList.getSelectedIndex()); + int idx = levelScriptList.getSelectedIndex(); + if (idx != -1) + { + levelScriptDataListModel.remove(idx); + commitLevelScriptChanges(); + } } void changeFieldVisibility(boolean setting) { @@ -624,6 +796,9 @@ private void levelScriptTypeSelectionChanged(ActionEvent e) { } private void labelDisplayListSelectionChanged(ListSelectionEvent e) { + if (e != null && e.getValueIsAdjusting()) + return; + textPane1.getHighlighter().removeAllHighlights(); if (!labelDisplayList.isSelectionEmpty()) @@ -643,6 +818,14 @@ private void labelDisplayListSelectionChanged(ListSelectionEvent e) { int index = text.indexOf(toFind); + if (index < 0) + { + // the jump lists are only rebuilt on entry switch and successful save, so + // they go stale as soon as the user edits the script + Toolkit.getDefaultToolkit().beep(); + return; + } + ScriptPane.gotoStartOfLine(textPane1, ScriptPane.getLineAtOffset(textPane1, index)); DefaultHighlighter.DefaultHighlightPainter highlightPainter = @@ -651,7 +834,7 @@ private void labelDisplayListSelectionChanged(ListSelectionEvent e) { highlightPainter); } catch (BadLocationException ex) { - throw new RuntimeException(ex); + ex.printStackTrace(); } } } @@ -951,6 +1134,11 @@ private void initComponents() { public void mousePressed(MouseEvent e) { levelScriptListMousePressed(e); } + + @Override + public void mouseReleased(MouseEvent e) { + levelScriptListMousePressed(e); + } }); scrollPane3.setViewportView(levelScriptList); } @@ -1090,6 +1278,12 @@ public Object getValueFor(int entryIdx, FieldScriptContents property) @Override public void setValueFor(Object aValue, int entryIdx, FieldScriptContents property) { + if (entryIdx < 0 || !(aValue instanceof GenericScriptData scriptData)) + return; + + getData().set(entryIdx, scriptData); + DataManager.markDirty(GenericScriptData.class); + // GenericScriptData entry = getData().get(entryIdx); // // switch (property) { diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/DefaultSheetPanel.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/DefaultSheetPanel.java index f0b2738..839977d 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/DefaultSheetPanel.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/DefaultSheetPanel.java @@ -12,6 +12,7 @@ import io.github.turtleisaac.nds4j.ui.ThemeUtils; import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; import io.github.turtleisaac.pokeditor.gui.PokeditorManager; import io.github.turtleisaac.pokeditor.gui.sheets.tables.DefaultTable; import io.github.turtleisaac.pokeditor.gui.sheets.tables.FrozenColumnTable; @@ -54,6 +55,9 @@ public DefaultSheetPanel(PokeditorManager manager, DefaultTable table) { scrollPane1.setCorner(JScrollPane.UPPER_LEFT_CORNER, frozenColumns.getCornerTableHeader()); + table.putClientProperty("terminateEditOnFocusLost", Boolean.TRUE); + frozenColumns.putClientProperty("terminateEditOnFocusLost", Boolean.TRUE); + table.getSelectionModel().addListSelectionListener(e -> frozenColumns.clearSelection()); frozenColumns.getSelectionModel().addListSelectionListener(e -> table.clearSelection()); @@ -156,25 +160,134 @@ private void zoomInButtonPressed(ActionEvent e) { resizeColumnWidth(this.table); } + /** + * Terminates any in-progress cell edit on both tables so the value the user just typed is + * committed to the model before it is read. Without this, typing a value and clicking Save + * writes the OLD value while the editor still displays the new one. + * @return false if an editor refused to stop (i.e. the typed value failed validation) + */ + private boolean stopEditing() + { + boolean stopped = true; + + if (table.isEditing()) + stopped = table.getCellEditor().stopCellEditing(); + + if (frozenColumns.isEditing()) + stopped &= frozenColumns.getCellEditor().stopCellEditing(); + + return stopped; + } + + /** + * @return the selected row, resolved from whichever of the two tables actually has a + * selection (they clear each other's), or -1 if neither does + */ + private int getSelectedModelRow() + { + int row = table.getSelectedRow(); + if (row < 0) + row = frozenColumns.getSelectedRow(); + return row; + } + private void addRowButtonPressed(ActionEvent e) { + if (!stopEditing()) + return; + List data = (List) table.getFormatModel().getData(); + if (data.isEmpty()) + { + JOptionPane.showMessageDialog(this, "This sheet has no rows to base a new row on.", "PokEditor", JOptionPane.ERROR_MESSAGE); + return; + } + + GenericFileData v; try { - GenericFileData v = data.get(0).getClass().getDeclaredConstructor().newInstance(); - data.add(v); + v = data.get(0).getClass().getDeclaredConstructor().newInstance(); + } + catch (NoSuchMethodException ex) { + /* + TODO: EvolutionData and LearnsetData (both owned by PokEditor-Core) declare only + a (BytesDataContainer) constructor, and GenericParser exposes no "create blank + entry" factory, so there is no way to build a blank row for those sheets from + here. Once Core gains a public no-arg constructor (or the parser gains a + factory method), route creation through it and drop this branch. + */ + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, + "Rows cannot be added to this sheet yet - the underlying data format (" + data.get(0).getClass().getSimpleName() + ")\ndoes not provide a way to create a blank entry.", + "Unsupported", JOptionPane.ERROR_MESSAGE); + return; } - catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException ex) { - throw new RuntimeException(ex); + catch (InvocationTargetException | InstantiationException | IllegalAccessException ex) { + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, + "A new row could not be created:\n" + ex.getMessage(), + "Error", JOptionPane.ERROR_MESSAGE); + return; } + + int newRow = data.size(); + data.add(v); + + // keep the parallel name bank the same length, otherwise typing a name into the new + // row throws + TextBankData nameBank = table.getFormatModel().getNameTextBank(); + if (nameBank != null) + { + while (nameBank.size() <= newRow) + nameBank.add(new TextBankData.Message("")); + } + + table.getFormatModel().fireTableRowsInserted(newRow, newRow); + if (frozenColumns.getModel() instanceof AbstractTableModel frozenModel) + frozenModel.fireTableRowsInserted(newRow, newRow); + + // other open sheets read the same name bank and have just had it grow underneath them + manager.nameBankRowsChanged(nameBank, this); + manager.markSheetDirty(table.getDataClass()); } private void deleteRowButtonPressed(ActionEvent e) { - // todo figure out how I'm going to remove the name of the species when a row is deleted (because right now the name just stays at that index and the data moves up) - table.getFormatModel().getData().remove(table.getSelectedRow()); + if (!stopEditing()) + return; + + int row = getSelectedModelRow(); + if (row < 0) + { + JOptionPane.showMessageDialog(this, "Select the row you would like to delete first.", "PokEditor", JOptionPane.INFORMATION_MESSAGE); + return; + } + + List data = table.getFormatModel().getData(); + if (row >= data.size()) + return; + + data.remove(row); + + // the name bank has to be shifted along with the data, otherwise every name below the + // deleted row labels the wrong entry + TextBankData nameBank = table.getFormatModel().getNameTextBank(); + if (nameBank != null && row < nameBank.size()) + nameBank.remove(row); + + table.getFormatModel().fireTableRowsDeleted(row, row); + if (frozenColumns.getModel() instanceof AbstractTableModel frozenModel) + frozenModel.fireTableRowsDeleted(row, row); + + // Personal, TM compatibility, Evolutions and Learnsets all read the same species name + // bank across three different data classes, so the sheets that are not being edited + // have just had every name below this row shifted under them. + manager.nameBankRowsChanged(nameBank, this); + manager.markSheetDirty(table.getDataClass()); } private void exportSheetButtonPressed(ActionEvent e) { - // TODO add your code here + if (!stopEditing()) + return; + manager.writeSheet(table.exportClean()); } @@ -184,17 +297,118 @@ private void importSheetButtonPressed(ActionEvent e) { } private void saveSheetButtonPressed(ActionEvent e) { + if (!stopEditing()) + return; + manager.saveData(table.getDataClass()); } private void reloadSheetButtonPressed(ActionEvent e) { + if (!stopEditing()) + return; + manager.resetData(table.getDataClass()); table.getFormatModel().fireTableDataChanged(); } private void findButtonPressed(ActionEvent e) { - // TODO add your code here - FindDialog findDialog = new FindDialog(this); + if (!stopEditing()) + return; + + new FindDialog(this).setVisible(true); + } + + /** + * Moves the selection to (and scrolls to) the provided cell. Used by {@link FindDialog}. + */ + public void selectCell(int row, int column) + { + table.clearSelection(); + frozenColumns.clearSelection(); + + if (column < 0) // a hit in one of the frozen ID/Name columns + { + frozenColumns.setRowSelectionInterval(row, row); + frozenColumns.scrollRectToVisible(frozenColumns.getCellRect(row, 0, true)); + return; + } + + table.setRowSelectionInterval(row, row); + table.setColumnSelectionInterval(column, column); + table.scrollRectToVisible(table.getCellRect(row, column, true)); + } + + public FrozenColumnTable getFrozenColumns() + { + return frozenColumns; + } + + /** + * @return how many frozen (ID/Name) columns precede column 0 of the main table + */ + public int getFrozenColumnCount() + { + return frozenColumns.getColumnCount(); + } + + /** + * The text a search should match against for the given cell - the text the user can + * actually see, so searching for a move or species name works. + * @param column the main table's column index, or a negative index into the frozen columns + */ + private String getSearchableText(int row, int column) + { + if (column < 0) + return String.valueOf(frozenColumns.getValueAt(row, column + getFrozenColumnCount())); + + TableCellRenderer renderer = table.getCellRenderer(row, column); + Component component = table.prepareRenderer(renderer, row, column); + if (component instanceof JLabel label) + return String.valueOf(label.getText()); + + return String.valueOf(table.getValueAt(row, column)); + } + + /** + * Searches the sheet for the provided text, starting just after the provided cell and + * wrapping around. There is no RowSorter in this project, so view and model coordinates + * are the same. + * @param fromColumn the column of the previous hit (may be negative for a frozen column); + * pass {@code -getFrozenColumnCount() - 1} to start at the very beginning + * @return {row, column} of the next match, or null if there is none + */ + public int[] findNext(String query, boolean matchCase, boolean matchEntireContents, int fromRow, int fromColumn) + { + int frozenCount = getFrozenColumnCount(); + int rowCount = table.getRowCount(); + int columnCount = table.getColumnCount(); + int totalColumns = frozenCount + columnCount; + + if (query == null || query.isEmpty() || rowCount == 0 || totalColumns == 0) + return null; + + String needle = matchCase ? query : query.toLowerCase(Locale.ROOT); + int totalCells = rowCount * totalColumns; + + int startIdx = Math.max(0, fromRow) * totalColumns + (fromColumn + frozenCount) + 1; + + for (int offset = 0; offset < totalCells; offset++) + { + int idx = Math.floorMod(startIdx + offset, totalCells); + int row = idx / totalColumns; + int column = (idx % totalColumns) - frozenCount; + + String cell = getSearchableText(row, column); + if (cell == null) + continue; + + String haystack = matchCase ? cell : cell.toLowerCase(Locale.ROOT); + + if (matchEntireContents ? haystack.equals(needle) : haystack.contains(needle)) + return new int[] {row, column}; + } + + return null; } private void copyModeButtonPressed(ActionEvent e) { diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/FindDialog.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/FindDialog.java index fb003b3..5e07444 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/FindDialog.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/FindDialog.java @@ -7,23 +7,79 @@ import java.awt.*; import java.util.*; import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; import net.miginfocom.swing.*; /** * @author turtleisaac */ public class FindDialog extends JFrame { + private final DefaultSheetPanel parent; + + /** the cell the last hit was found at, so "Find" continues from there and wraps around */ + private int lastRow; + private int lastColumn; + public FindDialog(DefaultSheetPanel parent) { super(); + this.parent = parent; initComponents(); - setPreferredSize(dialogPane.getPreferredSize()); - setMinimumSize(dialogPane.getPreferredSize()); - setMaximumSize(dialogPane.getPreferredSize()); - setVisible(true); + + // the stray extra search field in the generated layout is not wired up to anything + panel1.remove(label1); + panel1.remove(textField1); + panel1.remove(radioButton1); + + resetSearchPosition(); + findTextField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { resetSearchPosition(); } + + @Override + public void removeUpdate(DocumentEvent e) { resetSearchPosition(); } + + @Override + public void changedUpdate(DocumentEvent e) { resetSearchPosition(); } + }); + + findButton.addActionListener(e -> findButtonPressed()); + doneButton.addActionListener(e -> dispose()); + getRootPane().setDefaultButton(findButton); + + setDefaultCloseOperation(DISPOSE_ON_CLOSE); pack(); + setPreferredSize(getPreferredSize()); + setMinimumSize(getPreferredSize()); setLocationRelativeTo(parent); } + private void resetSearchPosition() + { + lastRow = 0; + lastColumn = -parent.getFrozenColumnCount() - 1; + } + + private void findButtonPressed() + { + String query = findTextField.getText(); + if (query == null || query.isEmpty()) + return; + + int[] hit = parent.findNext(query, matchCaseCheckbox.isSelected(), matchEntireContentsCheckbox.isSelected(), lastRow, lastColumn); + + if (hit == null) + { + JOptionPane.showMessageDialog(this, "No cell containing \"" + query + "\" was found.", "Find", JOptionPane.INFORMATION_MESSAGE); + resetSearchPosition(); + return; + } + + lastRow = hit[0]; + lastColumn = hit[1]; + parent.selectCell(lastRow, lastColumn); + } + private void initComponents() { // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents @formatter:off // Generated using JFormDesigner non-commercial license diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/DefaultTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/DefaultTable.java index c2ff430..63f3099 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/DefaultTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/DefaultTable.java @@ -6,6 +6,7 @@ import io.github.turtleisaac.pokeditor.formats.text.TextBankData; import io.github.turtleisaac.pokeditor.gui.PokeditorManager; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.TableCellComponents; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.BitfieldComboBoxEditor; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.CheckBoxEditor; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.ComboBoxCellEditor; @@ -37,6 +38,13 @@ public abstract class DefaultTable> private final CellTypes.CustomCellFunctionSupplier customCellSupplier; + /** + * while a paste is in progress the paste action reports failures itself (once, naming the + * offending cell) rather than having {@link #setValueAt(Object, int, int)} raise a dialog + * for every single rejected cell + */ + private boolean pasteInProgress; + public DefaultTable(FormatModel model, List textData, int[] widths, CellTypes.CustomCellFunctionSupplier customCellSupplier) { super(model); @@ -66,7 +74,7 @@ public DefaultTable(FormatModel model, List textData, int[] // setBackground(Color.WHITE); // setForeground(Color.black); - loadCellRenderers(obtainTextSources(textData)); + loadCellRenderers(buildColumnTextSources()); MultiLineTableHeaderRenderer renderer = new MultiLineTableHeaderRenderer(); Enumeration columns = getColumnModel().getColumns(); @@ -85,7 +93,7 @@ public DefaultTable(FormatModel model, List textData, int[] InputMap inputMap = getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); - PasteAction action = new PasteAction(this); + PasteAction action = new PasteAction<>(this); KeyStroke stroke; if (!SystemInfo.isMacOS) { @@ -132,66 +140,94 @@ public FormatModel getFormatModel() // public abstract int getNumFrozenColumns(); - public void loadCellRenderers(Queue textSources) + /** + * Walks the columns of this table once and maps each column which needs externally + * supplied text to the {@code String[]}s it needs, consuming the positional queue + * returned by {@link #obtainTextSources(List)} exactly once. + *

+ * Keying by column index (rather than having every consumer re-walk the queue in the + * same order) is what stops {@link #loadCellRenderers(Map)} and + * {@link #resetIndexedCellRendererText()} from drifting apart and handing a column the + * wrong list of names. + * @return a map of column index to the text sources that column's editor/renderer needs + */ + private Map buildColumnTextSources() { - TableCellEditor customEditor = null; - TableCellRenderer customRenderer = null; + Queue textSources = obtainTextSources(textData); + Map result = new HashMap<>(); + boolean customConsumed = false; for (int i = 0; i < getColumnCount(); i++) { CellTypes c = cellTypes[i]; - TableColumn col = getColumnModel().getColumn(i); - col.setWidth(widths[i]); - col.setPreferredWidth(widths[i]); - if (c == CellTypes.CHECKBOX) - { - col.setCellRenderer(new CheckBoxRenderer()); - col.setCellEditor(new CheckBoxEditor()); - } - else if (c == CellTypes.COMBO_BOX || c == CellTypes.COLORED_COMBO_BOX || c == CellTypes.BITFIELD_COMBO_BOX) + if (c == CellTypes.COMBO_BOX || c == CellTypes.COLORED_COMBO_BOX || c == CellTypes.BITFIELD_COMBO_BOX) { - String[] text = getTextFromSource(textSources); - - if (c != CellTypes.BITFIELD_COMBO_BOX) //normal and colored - col.setCellEditor(new ComboBoxCellEditor(text)); - else // bitfield combo box - col.setCellEditor(new BitfieldComboBoxEditor(text)); - - if (c == CellTypes.COMBO_BOX) - col.setCellRenderer(new IndexedStringCellRenderer(text)); - else if (c == CellTypes.COLORED_COMBO_BOX) - col.setCellRenderer(new IndexedStringCellRenderer.ColoredIndexedStringCellRenderer(text, PokeditorManager.typeColors)); - else - col.setCellRenderer(new BitfieldStringCellRenderer(text)); + result.put(i, new String[][] {getTextFromSource(textSources)}); } - else if (c == CellTypes.INTEGER) + else if (c == CellTypes.CUSTOM && !customConsumed) { - col.setCellEditor(new NumberOnlyCellEditor()); + customConsumed = true; + String[] speciesNames = getTextFromSource(textSources); + String[] itemNames = getTextFromSource(textSources); + String[] moveNames = getTextFromSource(textSources); + result.put(i, new String[][] {speciesNames, itemNames, moveNames}); } - else if (c == CellTypes.CUSTOM) + } + + return result; + } + + public void loadCellRenderers(Map textSources) + { + // the custom columns of a sheet all show the same kind of thing, so they share one + // editor and one renderer. the pair is built once, from the first custom column's + // text, because buildColumnTextSources only assigns the triple to that column - + // asking for it again on a later column would hand TableCellComponents a null. + TableCellComponents.Pair customPair = null; + + for (int i = 0; i < getColumnCount(); i++) + { + CellTypes c = cellTypes[i]; + TableColumn col = getColumnModel().getColumn(i); + col.setWidth(widths[i]); + col.setPreferredWidth(widths[i]); + + TableCellComponents.Pair pair; + if (c == CellTypes.CUSTOM) { - if (customEditor == null || customRenderer == null) + if (customPair == null) { - String[] speciesNames = getTextFromSource(textSources); - String[] itemNames = getTextFromSource(textSources); - String[] moveNames = getTextFromSource(textSources); - - customEditor = customCellSupplier.getEditor(speciesNames, itemNames, moveNames); - customRenderer = customCellSupplier.getRenderer(speciesNames, itemNames, moveNames); + customPair = TableCellComponents.forType(c, textSources.get(i), + getFormatModel().getCellValueRange(i), customCellSupplier); } + pair = customPair; + } + else { + pair = TableCellComponents.forType(c, textSources.get(i), + getFormatModel().getCellValueRange(i), customCellSupplier); + } - if (customEditor != null) - col.setCellEditor(customEditor); + if (pair.renderer() != null) + col.setCellRenderer(pair.renderer()); - if (customRenderer != null) - col.setCellRenderer(customRenderer); - } + if (pair.editor() != null) + col.setCellEditor(pair.editor()); } } private String[] getTextFromSource(Queue textSources) { + if (textSources.isEmpty()) + { + // remove() would throw NoSuchElementException with a null message, naming neither + // the sheet nor the column that went unserved + throw new IllegalStateException(String.format( + "%s supplied fewer text sources than its columns need. Every combo box column " + + "takes one list and a custom column takes three, so obtainTextSources " + + "must return that many.", getClass().getSimpleName())); + } + String[] text = textSources.remove(); if (text == null) text = new String[] {""}; @@ -201,7 +237,7 @@ private String[] getTextFromSource(Queue textSources) public void resetIndexedCellRendererText() { - Queue textSources = obtainTextSources(textData); + Map textSources = buildColumnTextSources(); for (int i = 0; i < getColumnCount(); i++) { CellTypes c = cellTypes[i]; @@ -209,19 +245,55 @@ public void resetIndexedCellRendererText() if (c == CellTypes.COMBO_BOX || c == CellTypes.COLORED_COMBO_BOX || c == CellTypes.BITFIELD_COMBO_BOX) { - String[] text = textSources.remove(); - if (text == null) - text = new String[] {""}; + String[][] entry = textSources.get(i); + String[] text = (entry == null || entry.length == 0 || entry[0] == null) ? new String[] {""} : entry[0]; ((ComboBoxCellEditor) col.getCellEditor()).setItems(text); ((IndexedStringCellRenderer) col.getCellRenderer()).setItems(text); } } } + /** + * Marks the data backing this sheet as dirty and reports validation failures thrown by + * the underlying data classes to the user instead of letting them escape onto the EDT + * where a user running a double-clicked jar would never see them. + */ + @Override + public void setValueAt(Object aValue, int row, int column) + { + try { + super.setValueAt(aValue, row, column); + DataManager.markDirty(getDataClass()); + } + catch (RuntimeException e) { + if (pasteInProgress) + throw e; + e.printStackTrace(); + JOptionPane.showMessageDialog(this, + String.format("The value \"%s\" could not be applied to row %d, column \"%s\":\n%s", + aValue, row, getColumnName(column), e.getMessage()), + "Invalid Value", JOptionPane.ERROR_MESSAGE); + } + } + public String[][] exportClean() { - String[][] output = new String[getModel().getRowCount()][getColumnCount()]; - for (int colIdx = 0; colIdx < output[0].length; colIdx++) + FormatModel frozenModel = getFormatModel().getFrozenColumnModel(); + int frozenColumnCount = frozenModel == null ? 0 : frozenModel.getColumnCount(); + + String[][] output = new String[getModel().getRowCount()][frozenColumnCount + getColumnCount()]; + + // the frozen ID/Name columns live in their own model, so they have to be pulled in + // explicitly - iterating getColumnModel() alone leaves them out of the export entirely + for (int colIdx = 0; colIdx < frozenColumnCount; colIdx++) + { + for (int rowIdx = 0; rowIdx < output.length; rowIdx++) + { + output[rowIdx][colIdx] = String.valueOf(frozenModel.getValueAt(rowIdx, colIdx)); + } + } + + for (int colIdx = 0; colIdx < getColumnCount(); colIdx++) { TableColumn column = getColumnModel().getColumn(colIdx); TableCellRenderer renderer = column.getCellRenderer(); @@ -229,14 +301,14 @@ public String[][] exportClean() { for (int rowIdx = 0; rowIdx < output.length; rowIdx++) { - output[rowIdx][colIdx] = ((DefaultTableCellRenderer) prepareRenderer(renderer, rowIdx, colIdx)).getText(); + output[rowIdx][frozenColumnCount + colIdx] = ((DefaultTableCellRenderer) prepareRenderer(renderer, rowIdx, colIdx)).getText(); } } else { for (int rowIdx = 0; rowIdx < output.length; rowIdx++) { - output[rowIdx][colIdx] = String.valueOf(getValueAt(rowIdx, colIdx)); + output[rowIdx][frozenColumnCount + colIdx] = String.valueOf(getValueAt(rowIdx, colIdx)); } } @@ -248,7 +320,9 @@ public String[][] exportClean() public String[][] exportEditable() { String[][] output = new String[getModel().getRowCount()][getColumnCount()]; - for (int colIdx = 0; colIdx < output[0].length; colIdx++) + // bounded by the column count, not by the width of row 0 - an empty sheet has no row 0, + // and asking for its width threw rather than returning the empty export it should + for (int colIdx = 0; colIdx < getColumnCount(); colIdx++) { for (int rowIdx = 0; rowIdx < output.length; rowIdx++) { @@ -270,15 +344,69 @@ public static String[] loadStringsFromKeys(String... keys) return result; } - public static class PasteAction extends AbstractAction { + public static class PasteAction> extends AbstractAction { + + /** how many rejected cells to name individually before falling back to a count */ + private static final int MAX_REPORTED_REJECTIONS = 8; + + /** + * An empty cell in a pasted block means "there is nothing here", not "write nothing here". + * A spreadsheet produces them for genuinely blank cells and for the ragged right-hand end + * of a copied range, and no numeric, checkbox or combo box column has a value that spelling + * denotes - parsing it either throws or, for a checkbox, silently clears the flag. + *

+ * Text columns are the exception: clearing a name is a real edit, so an empty string is a + * value there and is written through. + */ + private static boolean isBlankCell(String value, CellTypes cellType) + { + return value != null && value.isEmpty() && cellType != CellTypes.STRING; + } - private final DefaultTable> table; + private final DefaultTable table; - public PasteAction(DefaultTable> table) + public PasteAction(DefaultTable table) { this.table = table; } + /** + * Reports a paste failure to the user, or to the console when there is no display. + * Calling JOptionPane headlessly throws HeadlessException from inside the error path, + * which replaces the problem being reported with a different one - and makes the paste + * path untestable, which is why this defect reached a release in the first place. + */ + private static void report(Component parent, String message) + { + if (GraphicsEnvironment.isHeadless()) + { + System.err.println(message); + return; + } + JOptionPane.showMessageDialog(parent, message, "Paste Error", JOptionPane.ERROR_MESSAGE); + } + + /** + * The clipboard this action pastes from. + *

+ * Reached through a method rather than called inline so that a test can supply its own. + * The system clipboard needs a display, so without this seam the paste path can only be + * exercised by reflectively replacing the AWT toolkit - which is how a regression that + * refused every spreadsheet paste came to ship untested. + * + * @return the clipboard to read, or null if there is none to read + */ + protected Clipboard getClipboard() + { + try { + return Toolkit.getDefaultToolkit().getSystemClipboard(); + } + catch (HeadlessException | SecurityException e) { + // no display, or a sandbox that will not hand one over. nothing to paste from. + return null; + } + } + @Override public void actionPerformed(ActionEvent e) { @@ -286,52 +414,148 @@ public void actionPerformed(ActionEvent e) System.out.println("rows: " + Arrays.toString(rows)); int[] cols = table.getSelectedColumns(); - Clipboard cb = Toolkit.getDefaultToolkit().getSystemClipboard(); + Clipboard cb = getClipboard(); + if (cb == null) + return; + if (cb.isDataFlavorAvailable(DataFlavor.stringFlavor)) { try { String value = (String) cb.getData(DataFlavor.stringFlavor); - String[] lines = value.split("\n"); - String[][] pastedCells = new String[lines.length][]; - int idx = 0; - for (String line : lines) { - pastedCells[idx++] = line.split("\t"); + String[] lines = value.split("\r?\n", -1); + + // Every spreadsheet terminates the last row of a copied range with a newline, + // so splitting with -1 leaves a trailing empty line. Counting it as a row of + // data makes a 2x2 copy look like three rows: it inflates numVerticalCopies + // below, and its single empty cell is not a value any numeric column can take. + int lineCount = lines.length; + if (lineCount > 1 && lines[lineCount - 1].isEmpty()) + lineCount--; + + String[][] pastedCells = new String[lineCount][]; + for (int idx = 0; idx < lineCount; idx++) { + pastedCells[idx] = lines[idx].split("\t", -1); } - int numVerticalCopies = (int) Math.round(((double) rows.length) / pastedCells.length); - int numHorizontalCopies = (int) Math.round(((double) cols.length) / pastedCells[0].length); - + if (rows.length == 0 || cols.length == 0 || pastedCells.length == 0) + return; + + // integer floor, never zero - selecting a single cell and pasting several + // rows has to paste all of them, and selecting more rows than were copied + // must never write past the bottom of the selection + int numVerticalCopies = Math.max(1, rows.length / pastedCells.length); + int numHorizontalCopies = Math.max(1, cols.length / pastedCells[0].length); + + // Check the whole rectangle before writing any of it. The write loop below + // cannot roll back - there is no undo - so a value rejected part way through + // would leave the sheet holding some of the paste and not the rest, with no + // way to tell which. prepareObjectForWriting only converts and validates, so + // running it here costs nothing and changes nothing. + List rejections = new ArrayList<>(); + FormatModel model = table.getFormatModel(); for (int verticalCopyIdx = 0; verticalCopyIdx < numVerticalCopies; verticalCopyIdx++) { for (int horizontalCopyIdx = 0; horizontalCopyIdx < numHorizontalCopies; horizontalCopyIdx++) { for (int rowIdx = 0; rowIdx < pastedCells.length; rowIdx++) { - for (int colIdx = 0; colIdx < pastedCells[0].length; colIdx++) + for (int colIdx = 0; colIdx < pastedCells[rowIdx].length; colIdx++) { - int destRow = rows[0] + verticalCopyIdx * pastedCells.length + rowIdx; - int destCol = cols[0] + horizontalCopyIdx * pastedCells[0].length + colIdx; - -// System.out.println("Setting value at (" + destRow + "," + destCol + ") to: " + pastedCells[rowIdx][colIdx]); - table.setValueAt(pastedCells[rowIdx][colIdx], destRow, destCol); - table.getFormatModel().fireTableCellUpdated(destRow, destCol); + int checkRow = rows[0] + verticalCopyIdx * pastedCells.length + rowIdx; + int checkCol = cols[0] + horizontalCopyIdx * pastedCells[0].length + colIdx; + + if (checkRow >= table.getRowCount() || checkCol >= table.getColumnCount()) + continue; + + if (isBlankCell(pastedCells[rowIdx][colIdx], table.cellTypes[checkCol])) + continue; + + try { + model.prepareObjectForWriting(pastedCells[rowIdx][colIdx], + table.cellTypes[checkCol], model.getCellValueRange(checkCol)); + } + catch (RuntimeException ex) { + if (rejections.size() < MAX_REPORTED_REJECTIONS) + { + rejections.add(String.format("row %d, \"%s\": %s", + checkRow, table.getColumnName(checkCol), ex.getMessage())); + } + else { + rejections.add(null); // counted, not listed + } + } } } } } -// for (int userSelectedRow : rows) -// { -// for (int rowIdx = 0; rowIdx < pastedCells.length; rowIdx++) -// { -// for (int colIdx = 0; colIdx < pastedCells[0].length; colIdx++) -// { -// table.setValueAt(pastedCells[rowIdx][colIdx], userSelectedRow + rowIdx, cols[0] + colIdx); -// table.getFormatModel().fireTableCellUpdated(userSelectedRow + rowIdx, cols[0] + colIdx); -// } -// } -// } + if (!rejections.isEmpty()) + { + StringBuilder message = new StringBuilder(rejections.size() == 1 + ? "This value can't be pasted, so nothing was changed:\n\n" + : rejections.size() + " values can't be pasted, so nothing was changed:\n\n"); + int listed = 0; + for (String rejection : rejections) + { + if (rejection == null) + continue; + message.append(" \u2022 ").append(rejection).append('\n'); + listed++; + } + if (rejections.size() > listed) + message.append(" \u2022 ").append(rejections.size() - listed).append(" more\n"); + + report(table, message.toString()); + return; + } + + int destRow = -1; + int destCol = -1; + table.pasteInProgress = true; + try + { + for (int verticalCopyIdx = 0; verticalCopyIdx < numVerticalCopies; verticalCopyIdx++) + { + for (int horizontalCopyIdx = 0; horizontalCopyIdx < numHorizontalCopies; horizontalCopyIdx++) + { + for (int rowIdx = 0; rowIdx < pastedCells.length; rowIdx++) + { + for (int colIdx = 0; colIdx < pastedCells[rowIdx].length; colIdx++) + { + destRow = rows[0] + verticalCopyIdx * pastedCells.length + rowIdx; + destCol = cols[0] + horizontalCopyIdx * pastedCells[0].length + colIdx; + + if (destRow >= table.getRowCount() || destCol >= table.getColumnCount()) + continue; + + // must skip exactly what the dry run skipped, or the two + // passes disagree and a value reaches setValueAt unchecked + if (isBlankCell(pastedCells[rowIdx][colIdx], table.cellTypes[destCol])) + continue; + + table.setValueAt(pastedCells[rowIdx][colIdx], destRow, destCol); + table.getFormatModel().fireTableCellUpdated(destRow, destCol); + } + } + } + } + } + catch (RuntimeException ex) + { + // the dry run above passed, so reaching here means the write path rejected + // something the validation did not know about. name the cell rather than + // leaving the user to guess which of the pasted values was the problem. + ex.printStackTrace(); + report(table, String.format( + "The paste stopped at row %d, column \"%s\":%n%s%n%n" + + "Cells before this one have already been changed.", + destRow, table.getColumnName(destCol), ex.getMessage())); + } + finally + { + table.pasteInProgress = false; + } // table.setValueAt(value, row, col); } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModel.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModel.java index f0a2dde..c18f830 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModel.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModel.java @@ -18,6 +18,8 @@ public abstract class FormatModel> private boolean copyPasteModeEnabled; + public static final int[] DEFAULT_VALUE_RANGE = new int[] {0, 255}; + public FormatModel(List data, List textBankData) { this.data = data; @@ -101,17 +103,111 @@ protected CellTypes getCellType(int columnIndex) return CellTypes.STRING; } + /** + * The inclusive {min, max} range which the provided column's underlying storage can hold. + * Sheets which have columns wider (or narrower, or signed) than a single unsigned byte + * override this so the cell editors can reject out-of-range input instead of silently + * truncating it when the data is written back out to the ROM. + * @param columnIndex the column index, using the same numbering as {@link #getCellType(int)} + * @return an array of length 2, {minimum, maximum} + */ + public int[] getCellValueRange(int columnIndex) + { + return DEFAULT_VALUE_RANGE; + } + + /** + * The text bank which backs the frozen "Name" column of this sheet, if it has one. + * Rows added to or removed from a sheet have to be mirrored into it, otherwise every + * name below the affected row labels the wrong entry. + * @return the name bank, or null if this sheet has no parallel name bank + */ + public TextBankData getNameTextBank() + { + return null; + } + public abstract FormatModel getFrozenColumnModel(); public Object prepareObjectForWriting(Object aValue, CellTypes cellType) { - if (aValue instanceof String) + return prepareObjectForWriting(aValue, cellType, null); + } + + /** + * Converts an incoming cell value to the type the column stores, and refuses it if the + * column cannot hold it. + *

+ * The range check has to live here rather than in the cell editor. {@link + * io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.NumberOnlyCellEditor} + * was the only thing consulting {@link #getCellValueRange(int)}, and it is reached only + * by typing into an {@link CellTypes#INTEGER} cell - a paste goes straight to + * {@code setValueAt}, and combo box columns never had an editor-side check at all. So a + * pasted value simply landed in the data and was narrowed to something else when the + * file was written. + * + * @param valueRange the inclusive {min, max} this column can store, or null to skip the + * check (for columns where no numeric range applies) + */ + public Object prepareObjectForWriting(Object aValue, CellTypes cellType, int[] valueRange) + { + if (aValue instanceof String text) { + text = text.trim(); + if (cellType == CellTypes.CHECKBOX) - aValue = Boolean.parseBoolean(((String) aValue).trim()); + { + // Boolean.parseBoolean answers false for everything it does not recognise, so + // pasting a spreadsheet column of 1s and 0s silently cleared every checkbox + aValue = parseCheckbox(text); + } else if (cellType != CellTypes.STRING) - aValue = Integer.parseInt(((String) aValue).trim()); + { + try { + aValue = Integer.parseInt(text); + } + catch (NumberFormatException e) { + // the sheet exports rendered text - names, not indices - so pasting an + // exported column back in lands here for every combo box cell. say what + // the column actually wants rather than repeating parseInt's message + throw new IllegalArgumentException(String.format( + "\"%s\" is not a number. This column stores a number%s, so a name " + + "cannot be pasted into it - use the number that names it.", + text, valueRange == null ? "" + : " between " + valueRange[0] + " and " + valueRange[1]), e); + } + } + } + + if (valueRange != null && aValue instanceof Integer value + && (value < valueRange[0] || value > valueRange[1])) + { + throw new IllegalArgumentException(String.format( + "%d is outside the range %d to %d that this column can store. Saving it " + + "would write a different value than the one entered.", + value, valueRange[0], valueRange[1])); } + return aValue; } + + /** + * Accepts the spellings a checkbox column can actually receive - the true/false a cell + * editor produces and the 1/0 a spreadsheet paste produces - and refuses anything else + * rather than quietly reading it as false. + */ + private static boolean parseCheckbox(String text) + { + switch (text.toLowerCase()) + { + case "true": case "1": case "yes": case "y": + return true; + case "false": case "0": case "no": case "n": case "": + return false; + default: + throw new IllegalArgumentException(String.format( + "\"%s\" is not a yes or no value. This column is a checkbox; it accepts " + + "true/false or 1/0.", text)); + } + } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTable.java index 961652e..0a8e753 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTable.java @@ -1,6 +1,8 @@ package io.github.turtleisaac.pokeditor.gui.sheets.tables; +import io.github.turtleisaac.pokeditor.DataManager; import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.DefaultSheetCellRenderer; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.MultiLineTableHeaderRenderer; @@ -55,6 +57,26 @@ public FrozenColumnTable(TableModel model) setDefaultRenderer(Object.class, new DefaultSheetCellRenderer()); } + /** + * The frozen columns edit the parallel name bank, so writes have to mark it dirty, and + * validation failures thrown by the underlying data have to reach the user rather than + * escaping onto the EDT. + */ + @Override + public void setValueAt(Object aValue, int row, int column) + { + try { + super.setValueAt(aValue, row, column); + DataManager.markDirty(TextBankData.class); + } + catch (RuntimeException e) { + e.printStackTrace(); + JOptionPane.showMessageDialog(this, + String.format("The value \"%s\" could not be applied to row %d:%n%s", aValue, row, e.getMessage()), + "Invalid Value", JOptionPane.ERROR_MESSAGE); + } + } + public JTable getCornerTableHeader() { TableModel model = new DefaultTableModel() { @@ -73,7 +95,12 @@ public int getRowCount() @Override public Object getValueAt(int row, int column) { - return getModel().getColumnName(column - getColumnModel().getColumnCount()); + // corner cell c names frozen column c, so c is the index to ask for. This used + // to subtract the column count, handing getColumnName an index in [-n, -1] - + // outside its domain for every column. It produced the right strings only + // because the frozen models added the same offset straight back on; a model + // without that quirk got a blank header instead. + return getModel().getColumnName(column); } @Override diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/TableCellComponents.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/TableCellComponents.java new file mode 100644 index 0000000..cc3549c --- /dev/null +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/TableCellComponents.java @@ -0,0 +1,110 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.PokeditorManager; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.BitfieldComboBoxEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.CheckBoxEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.ComboBoxCellEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.NumberOnlyCellEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.BitfieldStringCellRenderer; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.CheckBoxRenderer; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.IndexedStringCellRenderer; + +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; + +/** + * Decides which renderer and editor a cell type gets. + *

+ * This used to live inside {@code DefaultTable.loadCellRenderers}, welded to the loop that + * installs the results on a {@code TableColumn}. That made the mapping unreachable from a + * test without building an entire sheet model, so the only way to check that every + * {@link CellTypes} constant was handled was to read the method's source text and look for + * its name - a test that depended on the working directory and could not tell a real + * dispatch from a mention in a comment. + *

+ * The pairing is a pure function of the cell type and the text it needs, so it belongs on + * its own where it can simply be called. + */ +public final class TableCellComponents +{ + private TableCellComponents() {} + + /** + * A renderer and editor for one column. Either half may be null, meaning "leave the + * table's default in place for this column". + */ + public record Pair(TableCellRenderer renderer, TableCellEditor editor) + { + static final Pair NONE = new Pair(null, null); + } + + /** + * @param type the cell type the column declares + * @param text the externally supplied names this column needs, or null when it needs + * none. A combo box column takes one list; a {@link CellTypes#CUSTOM} column + * takes three, in the order species, item, move. + * @param valueRange the inclusive {min, max} the column stores, used to bound the + * numeric editor + * @param customSupplier builds the pair for a {@link CellTypes#CUSTOM} column + * @return the pair for this column, never null + */ + public static Pair forType(CellTypes type, String[][] text, int[] valueRange, + CellTypes.CustomCellFunctionSupplier customSupplier) + { + switch (type) + { + case CHECKBOX: + return new Pair(new CheckBoxRenderer(), new CheckBoxEditor()); + + case COMBO_BOX: + { + String[] names = firstList(text); + return new Pair(new IndexedStringCellRenderer(names), new ComboBoxCellEditor(names)); + } + + case COLORED_COMBO_BOX: + { + String[] names = firstList(text); + return new Pair( + new IndexedStringCellRenderer.ColoredIndexedStringCellRenderer(names, PokeditorManager.typeColors), + new ComboBoxCellEditor(names)); + } + + case BITFIELD_COMBO_BOX: + { + String[] names = firstList(text); + return new Pair(new BitfieldStringCellRenderer(names), new BitfieldComboBoxEditor(names)); + } + + case INTEGER: + { + int[] range = valueRange == null ? new int[] {0, 255} : valueRange; + return new Pair(null, new NumberOnlyCellEditor(range[0], range[1])); + } + + case CUSTOM: + { + if (customSupplier == null || text == null || text.length < 3) + return Pair.NONE; + return new Pair(customSupplier.getRenderer(text[0], text[1], text[2]), + customSupplier.getEditor(text[0], text[1], text[2])); + } + + case STRING: + default: + // rendered by the table's own default renderer and edited as plain text + return Pair.NONE; + } + } + + /** + * A column that names its values is unusable without the names, but a missing list must + * not be allowed to reach the renderer as null and fail during painting. + */ + private static String[] firstList(String[][] text) + { + if (text == null || text.length == 0 || text[0] == null) + return new String[] {""}; + return text[0]; + } +} diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/BitfieldComboBoxEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/BitfieldComboBoxEditor.java index 9de5614..ebe5608 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/BitfieldComboBoxEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/BitfieldComboBoxEditor.java @@ -1,5 +1,7 @@ package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.BitfieldStringCellRenderer; + import javax.swing.*; import java.awt.*; @@ -14,6 +16,12 @@ public BitfieldComboBoxEditor(String[] items) public Object getCellEditorValue() { int val = comboBox.getSelectedIndex(); + // -1 is "nothing selected": either the cell holds a bit this column has no name for, or + // the user typed something that matched no entry. Either way the edit selected nothing, + // so the cell keeps what it had rather than being cleared - see ComboBoxCellEditor. + if (val < 0) + return getLastValue(); + if (val == 0) return 0; @@ -21,16 +29,23 @@ public Object getCellEditorValue() } @Override - public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) + protected void selectValue(Object value) { - int val = (Integer) value; + // must agree with BitfieldStringCellRenderer exactly, and by sharing its arithmetic + // rather than by restating it - the two used to round the same log expression at + // different points, so a value the sheet painted as one flag opened as another and + // closing the editor wrote that second flag back + int val = (value instanceof Integer i) ? i : 0; if (val == 0) - comboBox.setSelectedIndex(0); - else { - val = (int) (Math.log(val) / Math.log(2)) + 1; - comboBox.setSelectedIndex(val); + { + comboBox.setSelectedIndex(comboBox.getItemCount() > 0 ? 0 : -1); + } + else + { + int idx = BitfieldStringCellRenderer.highestSetBit(val) + 1; + // an undeclared bit has no entry to select; leaving the editor blank is honest, + // where setSelectedIndex would throw on the EDT and make the cell unopenable + comboBox.setSelectedIndex(idx >= 0 && idx < comboBox.getItemCount() ? idx : -1); } - - return comboBox; } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/CheckBoxEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/CheckBoxEditor.java index 27970c8..914d800 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/CheckBoxEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/CheckBoxEditor.java @@ -19,7 +19,9 @@ public CheckBoxEditor() @Override public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { - checkBox.setSelected((Boolean) value); + // see CheckBoxRenderer: a bare cast throws on null or on any non-Boolean, which would + // mean a cell the renderer can display but the user cannot open + checkBox.setSelected(value instanceof Boolean b && b); return panel; } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/ComboBoxCellEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/ComboBoxCellEditor.java index 1fb51e8..39daa78 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/ComboBoxCellEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/ComboBoxCellEditor.java @@ -11,6 +11,18 @@ public class ComboBoxCellEditor extends AbstractCellEditor implements TableCellE EditorComboBox comboBox; + /** + * The value the cell held when editing began. + *

+ * A combo box reports -1 when nothing is selected, which is what happens when the user + * types a name that does not match an entry exactly and then commits - type-to-search + * leaves the selection empty rather than guessing. Reporting -1 as the new value made the + * sheet reject the edit with an error, so typing a move name failed and the user had to + * find it in the list by hand. Handing back the original value instead means an edit that + * selected nothing simply does nothing, which is how the numeric editor already behaves. + */ + private Object lastValue; + public ComboBoxCellEditor(String[] items) { comboBox = new EditorComboBox(items); @@ -21,16 +33,52 @@ public void setItems(String[] items) comboBox = new EditorComboBox(items); } + /** + * @return the value the cell held when editing began, for a subclass that needs to leave it + * unchanged + */ + protected Object getLastValue() + { + return lastValue; + } + @Override public Object getCellEditorValue() { - return comboBox.getSelectedIndex(); + int selected = comboBox.getSelectedIndex(); + return selected >= 0 ? selected : lastValue; } + /** + * Final so that recording the incoming value cannot be forgotten. A subclass that overrode + * this and did not call super left {@link #lastValue} null, and an edit selecting nothing + * then cleared the cell instead of leaving it alone - this class's own bug, reintroduced one + * level down. Subclasses change how a value maps to an entry by overriding + * {@link #selectValue}, and the bookkeeping happens either way. + */ @Override - public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) + public final Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { - comboBox.setSelectedIndex((Integer) value); + lastValue = value; + selectValue(value); return comboBox; } + + /** + * Moves the selection to the entry representing {@code value}, or clears it when no entry + * does. + *

+ * The matching renderer ignores an out of range index and paints the cell harmlessly, so + * without the same tolerance here a cell can be displayed but not opened - and a value the + * sheet shows as wrong becomes one the user has no way to correct. Clamp to "no selection" + * instead of throwing on the EDT; {@link #getCellEditorValue()} turns that back into the + * value the cell already held rather than into -1. + * + * @param value the value the cell holds + */ + protected void selectValue(Object value) + { + int idx = (value instanceof Integer i) ? i : -1; + comboBox.setSelectedIndex(idx >= 0 && idx < comboBox.getItemCount() ? idx : -1); + } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/NumberOnlyCellEditor.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/NumberOnlyCellEditor.java index ef49b06..0665b57 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/NumberOnlyCellEditor.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/editors/NumberOnlyCellEditor.java @@ -10,44 +10,161 @@ public class NumberOnlyCellEditor extends AbstractCellEditor implements TableCellEditor { + public static final int DEFAULT_MINIMUM = 0; + public static final int DEFAULT_MAXIMUM = 255; + private final JTextField textField; + private final int minimum; + private final int maximum; private Object lastValue; public NumberOnlyCellEditor() { + this(DEFAULT_MINIMUM, DEFAULT_MAXIMUM); + } + + /** + * @param minimum the smallest value this column can legally hold (inclusive) + * @param maximum the largest value this column can legally hold (inclusive) + */ + public NumberOnlyCellEditor(int minimum, int maximum) + { + this.minimum = minimum; + this.maximum = maximum; + textField = new JTextField(); ((AbstractDocument) textField.getDocument()).setDocumentFilter(new DocumentFilter() { @Override public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException { - fb.insertString(offset, string.replaceAll("\\D++", ""), attr); + fb.insertString(offset, sanitize(offset, string), attr); } @Override public void replace(FilterBypass fb, int off, int len, String str, AttributeSet attr) throws BadLocationException { - fb.replace(off, len, str.replaceAll("\\D++", ""), attr); + fb.replace(off, len, sanitize(off, str), attr); } }); } + /** + * @return whether the column this editor serves is allowed to hold negative values + */ + private boolean isSigned() + { + return minimum < 0; + } + + /** + * Strips out every character which is not a digit, but permits a single leading minus + * sign when the column this editor serves is signed. A minus sign anywhere but at + * offset 0 is rejected. + */ + private String sanitize(int offset, String string) + { + if (string == null) + return ""; + + String digits = string.replaceAll("\\D++", ""); + + if (isSigned() && offset == 0 && string.startsWith("-")) + return "-" + digits; + + return digits; + } + + public int getMinimum() + { + return minimum; + } + + public int getMaximum() + { + return maximum; + } + + /** + * @return whether {@code value} is a cell with nothing in it. Blank is a real state, not a + * malformed one: a Learnsets row is only as long as that species' learnset, and every column + * past its last entry reads back null - which is most of that sheet. + */ + private static boolean isBlank(Object value) + { + return value == null || (value instanceof String s && s.trim().isEmpty()); + } + @Override public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { - lastValue = String.valueOf(value); + // the value itself, not String.valueOf(it) - which turned a blank cell into the four + // letters "null", and getCellEditorValue then handed that back as the cell's new + // contents for the write path to fail on + lastValue = value; if (value instanceof String) textField.setText((String) value); else if (value instanceof Integer) textField.setText(((Integer) value).toString()); + else + textField.setText(""); // a blank cell - do not leave the previous cell's text behind return textField; } + @Override + public boolean stopCellEditing() + { + String text = textField.getText().trim(); + + // A blank cell that is still blank is not an invalid entry, it is no entry: the user + // opened a cell, typed nothing, and clicked away. Rejecting that put a modal error in + // front of them for changing nothing, and on the Learnsets sheet - where most cells are + // blank - a stray double click was enough. Cancelling ends the edit without a write, + // which also matters because writing here grows the learnset: setValueFor pads every + // entry up to the one being set, so committing a blank cell would inject junk moves. + if (text.isEmpty() && isBlank(lastValue)) + { + cancelCellEditing(); + return false; + } + + int value; + try { + value = Integer.parseInt(text); + } + catch (NumberFormatException e) { + reportInvalidValue(text); + return false; + } + + if (value < minimum || value > maximum) + { + reportInvalidValue(text); + return false; + } + + return super.stopCellEditing(); + } + + private void reportInvalidValue(String text) + { + JOptionPane.showMessageDialog(textField, + String.format("\"%s\" is not a valid value for this cell.\nThis cell accepts whole numbers between %d and %d.", text, minimum, maximum), + "Invalid Value", + JOptionPane.ERROR_MESSAGE); + textField.requestFocusInWindow(); + } + @Override public Object getCellEditorValue() { - int val = Integer.parseInt((String) lastValue); - if (val >= 0 || val <= 255) - return textField.getText(); + String text = textField.getText().trim(); + try { + int value = Integer.parseInt(text); + if (value >= minimum && value <= maximum) + return text; + } + catch (NumberFormatException ignored) {} + return lastValue; } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/BitfieldStringCellRenderer.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/BitfieldStringCellRenderer.java index 807ed2b..e9509e6 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/BitfieldStringCellRenderer.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/BitfieldStringCellRenderer.java @@ -19,17 +19,38 @@ public Component getTableCellRendererComponent(JTable table, Object value, boole if (value instanceof Integer val) { if (val == 0) { - setText(items[0]); + if (items.length > 0) + setText(items[0]); return this; } - val = (int) (Math.log(val) / Math.log(2) + 1); - if (val < items.length) { - this.setText(items[val]); + int idx = highestSetBit(val) + 1; + if (idx >= 0 && idx < items.length) { + this.setText(items[idx]); } } } return this; } + + /** + * The index of the highest set bit of {@code val}, counting from zero, so entry + * {@code n} of the name list is the flag {@code 1 << (n - 1)}. + *

+ * This is integer arithmetic on purpose. The obvious {@code Math.log(val) / Math.log(2)} + * is a floating point approximation of an exact quantity: it returns NaN for a negative + * value (which then casts to 0, silently naming the wrong flag), and it is only as + * accurate as the last bit of a double. The renderer and the editor also used to round it + * at different points - {@code (int)(log + 1)} against {@code (int)log + 1} - so for a + * sign-extended value the two disagreed about which flag the cell held, and merely opening + * such a cell and closing it again rewrote it. + * + * @param val a non-zero bitfield value + * @return the zero-based position of its most significant set bit + */ + public static int highestSetBit(int val) + { + return 31 - Integer.numberOfLeadingZeros(val); + } } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/CheckBoxRenderer.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/CheckBoxRenderer.java index 8a94f7a..8cfb346 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/CheckBoxRenderer.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/CheckBoxRenderer.java @@ -27,7 +27,10 @@ public CheckBoxRenderer() public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { super.getTableCellRendererComponent(table, "", isSelected, hasFocus, row, column); - checkBox.setSelected((Boolean) value); + // a bare (Boolean) cast here is a paint-path landmine: null throws NPE and anything + // else throws ClassCastException, either of which kills the whole sheet over one cell. + // anything that is not a true Boolean simply reads as unticked. + checkBox.setSelected(value instanceof Boolean b && b); return panel; } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/IndexedStringCellRenderer.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/IndexedStringCellRenderer.java index ef08739..2d9760d 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/IndexedStringCellRenderer.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/renderers/IndexedStringCellRenderer.java @@ -26,17 +26,28 @@ public Component getTableCellRendererComponent(JTable table, Object value, boole { if (value instanceof Integer val) { - if (val < items.length) + if (val >= 0 && val < items.length) { this.setText(items[val]); } } else if (value instanceof String s) { - int val = Integer.parseInt(s); - if (val < items.length) + // a cell can hold text which is not a number at all - an empty cell, a partial + // edit, a bad paste. this runs on the paint path, so an unguarded parse would + // take the whole sheet down over one cell rather than just showing that cell oddly + try { - this.setText(items[val]); + int val = Integer.parseInt(s.trim()); + if (val >= 0 && val < items.length) + { + this.setText(items[val]); + } + } + catch (NumberFormatException ignored) + { + // leave the raw value the superclass already set - showing the user what is + // actually stored is more use than showing them nothing } } } @@ -61,9 +72,15 @@ public Component getTableCellRendererComponent(JTable table, Object value, boole // Border border = getBorder(); if (!isSelected && value != null) { - if (value instanceof Integer) + if (value instanceof Integer val) { - this.setBackground(colors[(int) value]); // always in bounds because of earlier check + // NOTE: the superclass checks against items.length, but this array is a + // separate (and shorter) list of type colors, so it needs its own check - + // an out of range type value used to make the sheet permanently unpaintable + if (val < 0 || val >= colors.length) + return this; + + this.setBackground(colors[val]); // this.setForeground(Color.black); // setBorder(border); } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/EvolutionsTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/EvolutionsTable.java index 9a2364d..c4b2887 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/EvolutionsTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/EvolutionsTable.java @@ -98,12 +98,12 @@ public void setValueFor(Object aValue, int rowIndex, EvolutionsColumn property) { EvolutionData species = getData().get(rowIndex); - aValue = prepareObjectForWriting(aValue, property.cellType); + aValue = prepareObjectForWriting(aValue, property.cellType, getCellValueRange(property.idx)); if (property.idx >= 0) { int entryIdx = property.repetition / EvolutionsColumn.NUMBER_OF_COLUMNS.idx; - while (entryIdx > species.size()) + while (entryIdx >= species.size()) { species.add(new EvolutionData.EvolutionEntry()); } @@ -143,10 +143,13 @@ public Object getValueFor(int rowIndex, EvolutionsColumn property) if (property.idx >= 0) { int entryIdx = property.repetition / EvolutionsColumn.NUMBER_OF_COLUMNS.idx; - while (entryIdx > species.size()) - { - species.add(new EvolutionData.EvolutionEntry()); - } + + // read path called from getValueAt during painting - it must not grow the list. + // 0 is the neutral value here ("no evolution"), and the requirement/result + // renderers read the neighbouring method column, so it must not be null. + if (entryIdx >= species.size()) + return 0; + EvolutionData.EvolutionEntry entry = species.get(entryIdx); switch (property) { @@ -187,6 +190,19 @@ protected CellTypes getCellType(int columnIndex) return EvolutionsColumn.getColumn(columnIndex).cellType; } + @Override + public int[] getCellValueRange(int columnIndex) + { + return new int[] {0, 0xFFFF}; // every evolution field is stored as a u16 + } + + + @Override + public TextBankData getNameTextBank() + { + return getTextBankData().get(TextFiles.SPECIES_NAMES.getValue()); + } + @Override public FormatModel getFrozenColumnModel() { @@ -197,6 +213,18 @@ public int getColumnCount() return super.getNumFrozenColumns(); } + @Override + public String getColumnName(int column) + { + // this model presents only the frozen columns, so its column 0 is the sheet's + // first frozen column. FormatModel.getColumnName adds getNumFrozenColumns() + // back on, which for this wrapper is its whole width - without undoing that + // here, asking it for the name of column 0 answers with the first UNfrozen + // column instead. getCornerTableHeader used to compensate by passing a + // negative index; two wrongs that happened to cancel. + return super.getColumnName(column - super.getNumFrozenColumns()); + } + @Override public Object getValueAt(int rowIndex, int columnIndex) { @@ -232,7 +260,7 @@ public EvolutionRequirementCellEditor(String[] speciesNames, String[] itemNames, speciesRequirementEditor = new ComboBoxCellEditor(speciesNames); itemRequirementEditor = new ComboBoxCellEditor(itemNames); moveRequirementEditor = new ComboBoxCellEditor(moveNames); - intValueRequirementEditor = new NumberOnlyCellEditor(); + intValueRequirementEditor = new NumberOnlyCellEditor(0, 0xFFFF); current = null; } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/LearnsetsTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/LearnsetsTable.java index 239a6e7..3c3819a 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/LearnsetsTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/LearnsetsTable.java @@ -82,12 +82,12 @@ public void setValueFor(Object aValue, int rowIdx, LearnsetsColumn property) { LearnsetData learnset = getData().get(rowIdx); - aValue = prepareObjectForWriting(aValue, property.cellType); + aValue = prepareObjectForWriting(aValue, property.cellType, property.getValueRange()); if (property.idx >= 0) { int entryIdx = property.repetition / LearnsetsColumn.NUMBER_OF_COLUMNS.idx; - while (entryIdx > learnset.size()) + while (entryIdx >= learnset.size()) { learnset.add(new LearnsetData.LearnsetEntry()); } @@ -126,10 +126,14 @@ public Object getValueFor(int rowIndex, LearnsetsColumn property) if (property.idx >= 0) { int entryIdx = property.repetition / LearnsetsColumn.NUMBER_OF_COLUMNS.idx; - while (entryIdx >= learnset.size()) - { - learnset.add(new LearnsetData.LearnsetEntry()); - } + + // this is a read path called from getValueAt during painting - it must NOT grow + // the learnset, or merely scrolling the sheet would permanently inject junk + // moves (a padding entry serialises as 0x0000, not the 0xFFFF terminator). + // The list is only grown by setValueFor, when the user actually types something. + if (entryIdx >= learnset.size()) + return null; + LearnsetData.LearnsetEntry entry = learnset.get(entryIdx); switch (property) { @@ -167,6 +171,24 @@ protected CellTypes getCellType(int columnIndex) return LearnsetsColumn.getColumn(columnIndex).cellType; } + @Override + public int[] getCellValueRange(int columnIndex) + { + if (columnIndex >= 0) + { + return LearnsetsColumn.getColumn(columnIndex % LearnsetsColumn.NUMBER_OF_COLUMNS.idx).getValueRange(); + } + + return LearnsetsColumn.getColumn(columnIndex).getValueRange(); + } + + + @Override + public TextBankData getNameTextBank() + { + return getTextBankData().get(TextFiles.SPECIES_NAMES.getValue()); + } + @Override public FormatModel getFrozenColumnModel() { @@ -177,6 +199,18 @@ public int getColumnCount() return super.getNumFrozenColumns(); } + @Override + public String getColumnName(int column) + { + // this model presents only the frozen columns, so its column 0 is the sheet's + // first frozen column. FormatModel.getColumnName adds getNumFrozenColumns() + // back on, which for this wrapper is its whole width - without undoing that + // here, asking it for the name of column 0 answers with the first UNfrozen + // column instead. getCornerTableHeader used to compensate by passing a + // negative index; two wrongs that happened to cancel. + return super.getColumnName(column - super.getNumFrozenColumns()); + } + @Override public Object getValueAt(int rowIndex, int columnIndex) { @@ -218,6 +252,18 @@ enum LearnsetsColumn this.cellType = cellType; } + /** + * @return the inclusive {min, max} range this column's underlying storage can hold + */ + int[] getValueRange() + { + return switch (this) { + case MOVE -> new int[] {0, 511}; // 9 bits + case LEVEL -> new int[] {0, 127}; // 7 bits + default -> new int[] {0, 0xFFFF}; + }; + } + static LearnsetsColumn getColumn(int idx) { for (LearnsetsColumn column : LearnsetsColumn.values()) diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/MovesTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/MovesTable.java index ced2dc0..2720a00 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/MovesTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/MovesTable.java @@ -82,7 +82,7 @@ public void setValueFor(Object aValue, int entryIdx, MovesColumn property) MoveData entry = getData().get(entryIdx); TextBankData moveNames = getTextBankData().get(TextFiles.MOVE_NAMES.getValue()); - aValue = prepareObjectForWriting(aValue, property.cellType); + aValue = prepareObjectForWriting(aValue, property.cellType, property.getValueRange()); switch (property) { case ID -> {} @@ -180,6 +180,19 @@ protected CellTypes getCellType(int columnIndex) return MovesColumn.getColumn(columnIndex).cellType; } + @Override + public int[] getCellValueRange(int columnIndex) + { + return MovesColumn.getColumn(columnIndex).getValueRange(); + } + + + @Override + public TextBankData getNameTextBank() + { + return getTextBankData().get(TextFiles.MOVE_NAMES.getValue()); + } + @Override public FormatModel getFrozenColumnModel() { @@ -190,6 +203,18 @@ public int getColumnCount() return super.getNumFrozenColumns(); } + @Override + public String getColumnName(int column) + { + // this model presents only the frozen columns, so its column 0 is the sheet's + // first frozen column. FormatModel.getColumnName adds getNumFrozenColumns() + // back on, which for this wrapper is its whole width - without undoing that + // here, asking it for the name of column 0 answers with the first UNfrozen + // column instead. getCornerTableHeader used to compensate by passing a + // negative index; two wrongs that happened to cancel. + return super.getColumnName(column - super.getNumFrozenColumns()); + } + @Override public Object getValueAt(int rowIndex, int columnIndex) { @@ -253,6 +278,18 @@ enum MovesColumn this.cellType = cellType; } + /** + * @return the inclusive {min, max} range this column's underlying storage can hold + */ + int[] getValueRange() + { + return switch (this) { + case EFFECT, TARGET -> new int[] {0, 0xFFFF}; // written as a short + case PRIORITY -> new int[] {-128, 127}; // written as a signed byte + default -> new int[] {0, 255}; + }; + } + static MovesColumn getColumn(int idx) { for (MovesColumn column : MovesColumn.values()) diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/PersonalTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/PersonalTable.java index a9dd1ea..399cb33 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/PersonalTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/PersonalTable.java @@ -1,6 +1,7 @@ package io.github.turtleisaac.pokeditor.gui.sheets.tables.formats; import io.github.turtleisaac.pokeditor.formats.personal.PersonalData; +import io.github.turtleisaac.pokeditor.gui.PokeditorManager; import io.github.turtleisaac.pokeditor.formats.text.TextBankData; import io.github.turtleisaac.pokeditor.gamedata.TextFiles; import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; @@ -84,7 +85,7 @@ public void setValueFor(Object aValue, int entryIdx, PersonalColumns property) PersonalData entry = getData().get(entryIdx); TextBankData speciesNames = getTextBankData().get(TextFiles.SPECIES_NAMES.getValue()); - aValue = prepareObjectForWriting(aValue, property.cellType); + aValue = prepareObjectForWriting(aValue, property.cellType, property.getValueRange()); switch (property) { case ID -> {} @@ -250,6 +251,19 @@ protected CellTypes getCellType(int columnIndex) return PersonalColumns.getColumn(columnIndex).cellType; } + @Override + public int[] getCellValueRange(int columnIndex) + { + return PersonalColumns.getColumn(columnIndex).getValueRange(); + } + + + @Override + public TextBankData getNameTextBank() + { + return getTextBankData().get(TextFiles.SPECIES_NAMES.getValue()); + } + @Override public FormatModel getFrozenColumnModel() { @@ -260,6 +274,18 @@ public int getColumnCount() return super.getNumFrozenColumns(); } + @Override + public String getColumnName(int column) + { + // this model presents only the frozen columns, so its column 0 is the sheet's + // first frozen column. FormatModel.getColumnName adds getNumFrozenColumns() + // back on, which for this wrapper is its whole width - without undoing that + // here, asking it for the name of column 0 answers with the first UNfrozen + // column instead. getCornerTableHeader used to compensate by passing a + // negative index; two wrongs that happened to cancel. + return super.getColumnName(column - super.getNumFrozenColumns()); + } + @Override public Object getValueAt(int rowIndex, int columnIndex) { @@ -326,6 +352,29 @@ enum PersonalColumns { this.cellType = cellType; } + /** + * @return the inclusive {min, max} range this column's underlying storage can hold + */ + int[] getValueRange() + { + return switch (this) { + // the EV yields are packed two bits apiece into a single short + case HP_EV_YIELD, ATK_EV_YIELD, DEF_EV_YIELD, SPEED_EV_YIELD, SP_ATK_EV_YIELD, SP_DEF_EV_YIELD -> new int[] {0, 3}; + // the dex color shares its byte with the flip flag (bit 7) + case COLOR -> new int[] {0, 127}; + // A type is stored in a whole byte, so this bound is not about storage - it is + // the number of types this sheet can name and colour. PokeditorManager.typeColors + // has one entry per Generation 4 type, and a value past the end has no name to + // show and no colour to draw. Core deliberately does not enforce it: a ROM hack + // with more types is valid data, and refusing to store it there would make the + // file unopenable rather than merely awkward to edit here. Raising typeColors is + // what widens this. + case TYPE_1, TYPE_2 -> new int[] {0, PokeditorManager.typeColors.length - 1}; + case UNCOMMON_HELD_ITEM, RARE_HELD_ITEM -> new int[] {0, 0xFFFF}; + default -> new int[] {0, 255}; + }; + } + static PersonalColumns getColumn(int idx) { for (PersonalColumns column : PersonalColumns.values()) diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/TmCompatibilityTable.java b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/TmCompatibilityTable.java index 8320b15..a07dba7 100644 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/TmCompatibilityTable.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/TmCompatibilityTable.java @@ -22,15 +22,35 @@ public class TmCompatibilityTable extends DefaultTable { + /** + * The TM/HM tables live on the parser instance rather than in statics, so that opening a + * second ROM cannot write the first ROM's TM list into it. Guice binds this as a singleton, + * so this is the same instance that populated the tables while parsing. + */ + private static PersonalParser personalParser() + { + return (PersonalParser) DataManager.getParser(PersonalData.class); + } + //todo 0xF0BFC is address of TMs table static final int[] columnWidths = new int[102]; static { Arrays.fill(columnWidths, 120); } - public TmCompatibilityTable(List data, List textData) + private final List moves; + + /** + * @param moves the move list the TM header editor reassigns from. Held rather than fetched + * on demand: this table has no ROM to fetch with, and the header listener used + * to ask DataManager with a null one - which since the caches became ROM-scoped + * discards every loaded sheet and then throws. resetData refills the same list + * object, so holding the reference stays correct across a reload. + */ + public TmCompatibilityTable(List data, List textData, List moves) { super(new TmCompatibilityModel(data, textData), textData, columnWidths, null); + this.moves = moves; String[] moveNames = textData.get(TextFiles.MOVE_NAMES.getValue()).getStringList().toArray(String[]::new); TableCellRenderer renderer = new DefaultTableCellRenderer() { @@ -108,7 +128,7 @@ public String getColumnName(int column) { if (column < 0) return super.getColumnName(column); - return "" + PersonalParser.tmMoveIdNumbers[column]; + return "" + personalParser().getTmMoveIdNumber(column); } @Override @@ -180,6 +200,13 @@ protected CellTypes getCellType(int columnIndex) return CellTypes.CHECKBOX; } + + @Override + public TextBankData getNameTextBank() + { + return getTextBankData().get(TextFiles.SPECIES_NAMES.getValue()); + } + @Override public FormatModel getFrozenColumnModel() { @@ -190,6 +217,18 @@ public int getColumnCount() return super.getNumFrozenColumns(); } + @Override + public String getColumnName(int column) + { + // this model presents only the frozen columns, so its column 0 is the sheet's + // first frozen column. FormatModel.getColumnName adds getNumFrozenColumns() + // back on, which for this wrapper is its whole width - without undoing that + // here, asking it for the name of column 0 answers with the first UNfrozen + // column instead. getCornerTableHeader used to compensate by passing a + // negative index; two wrongs that happened to cancel. + return super.getColumnName(column - super.getNumFrozenColumns()); + } + @Override public Object getValueAt(int rowIndex, int columnIndex) { @@ -267,11 +306,15 @@ public void mouseClicked(MouseEvent event) items.addElement(new EditorComboBox.ComboBoxItem(moveName)); } JList list = new JList<>(items); - list.setSelectedIndex(PersonalParser.tmMoveIdNumbers[columnIndex]); + list.setSelectedIndex(personalParser().getTmMoveIdNumber(columnIndex)); list.addListSelectionListener(e -> { - PersonalParser.updateTmType(columnIndex, list.getSelectedIndex(), DataManager.getData(null, MoveData.class)); + personalParser().updateTmType(columnIndex, list.getSelectedIndex(), moves); column.setHeaderValue(list.getSelectedIndex()); + // the reassignment lives on the parser, not in any sheet's data, so + // nothing else marks it. Without this the edit is real, is written by + // the next save, and yet the exit prompt reports no unsaved changes. + DataManager.markDirty(PersonalData.class); }); popupMenu.setPreferredSize( diff --git a/src/main/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTree.java b/src/main/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTree.java index 00442b3..9a7f884 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTree.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTree.java @@ -101,6 +101,9 @@ void fireCheckChangeEvent(CheckChangeEvent evt) { // Override public void setModel(TreeModel newModel) { super.setModel(newModel); + // the cached root has to follow the model, otherwise checkRoot() NPEs after a model swap + Object newRoot = newModel == null ? null : newModel.getRoot(); + root = newRoot instanceof DefaultMutableTreeNode node ? node : null; resetCheckingState(); } @@ -318,6 +321,9 @@ public void checkSubTree(TreePath tp, boolean check) { public void checkRoot() { + if (root == null) // no model has been set (or its root is not a DefaultMutableTreeNode) + return; + checkSubTree(new TreePath(root.getPath()), true); } diff --git a/src/main/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculator.java b/src/main/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculator.java index aeae469..c623a9d 100755 --- a/src/main/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculator.java +++ b/src/main/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculator.java @@ -22,7 +22,7 @@ public static void main(String[] args) public static int bruteForcePid(int targetPid, int trainerIdx, int trainerClassIdx, boolean trainerClassMale, int speciesIdx, int level) { - for(int i= 0; i < 65535; i++) + for(int i= 0; i <= 65535; i++) { if(generatePid(trainerIdx,trainerClassIdx,trainerClassMale,speciesIdx,level,i, 0, false) == targetPid) { @@ -61,8 +61,11 @@ public static int generatePid(int trainerIdx, int trainerClassIdx, boolean train public static long rndFlagCall() { - return (random() | (random() << 16)); -// return ((random() << 16) | random()); + // each draw contributes its own high half - shifting the raw (unmasked) 64-bit product + // used to splice bits 16-31 of one draw onto bits 0-15 of the next, producing garbage + long high= (random() >>> 16) & 0xffff; + long low= (random() >>> 16) & 0xffff; + return (high << 16) | low; } public static long idSetCall(long id, int pid) @@ -173,8 +176,8 @@ public static void setRandom(long newSeed) public static long random() { - long result= 0x41c64e6d * seed + 0x6073; - seed= result & 0xffffffffL; + long result= (0x41c64e6d * seed + 0x6073) & 0xffffffffL; + seed= result; return result; } } diff --git a/src/main/java/io/github/turtleisaac/variabletracker/Main.java b/src/main/java/io/github/turtleisaac/variabletracker/Main.java new file mode 100644 index 0000000..8840192 --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/Main.java @@ -0,0 +1,88 @@ +package io.github.turtleisaac.variabletracker; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.formdev.flatlaf.FlatDarkLaf; +import io.github.turtleisaac.variabletracker.gui.variables.VariableTracker; + +import javax.swing.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class Main +{ + private static final String sourceData = "[ {\n" + + " \"variableID\" : 16416,\n" + + " \"variableName\" : \"VAR_TUTORIAL_PROGRESS\",\n" + + " \"variableDescription\" : \"\",\n" + + " \"temp\" : false,\n" + + " \"variableValues\" : [ {\n" + + " \"value\" : 0,\n" + + " \"valueName\" : \"INCOMPLETE\",\n" + + " \"valueDescription\" : \"\"\n" + + " } ]\n" + + "}, {\n" + + " \"variableID\" : 16417,\n" + + " \"variableName\" : \"VAR_THING_2\",\n" + + " \"variableDescription\" : \"\",\n" + + " \"temp\" : false,\n" + + " \"variableValues\" : [ {\n" + + " \"value\" : 0,\n" + + " \"valueName\" : \"INCOMPLETE\",\n" + + " \"valueDescription\" : \"\"\n" + + " }, {\n" + + " \"value\" : 1,\n" + + " \"valueName\" : \"FIRST_STEP\",\n" + + " \"valueDescription\" : \"\"\n" + + " }, {\n" + + " \"value\" : 2,\n" + + " \"valueName\" : \"COMPLETE\",\n" + + " \"valueDescription\" : \"\"\n" + + " } ]\n" + + "}, {\n" + + " \"variableID\" : 16418,\n" + + " \"variableName\" : \"VAR_THING_3\",\n" + + " \"variableDescription\" : \"\",\n" + + " \"temp\" : false,\n" + + " \"variableValues\" : [ ]\n" + + "} ]"; + + public static void main(String[] args) + { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + List variableList = null; + + try { + variableList = new ArrayList<>(Arrays.asList(objectMapper.readValue(sourceData, ScriptVariable[].class))); + } + catch(JsonProcessingException e) { + throw new RuntimeException(e); + } +// System.setProperty( "apple.laf.useScreenMenuBar", "true" ); + + FlatDarkLaf.install(); + JFrame frame = new JFrame("Variable Tracker"); + VariableTracker variableTracker = new VariableTracker(variableList); + frame.setContentPane(variableTracker); + frame.setJMenuBar(variableTracker.getMenuBar()); +// frame.add(variableTracker.getMenuBar1()) + frame.setVisible(true); + frame.pack(); + } + + public static T fromJSON(final TypeReference type, + final String jsonPacket) { + T data = null; + + try { + data = new ObjectMapper().readValue(jsonPacket, type); + } catch (Exception e) { + // Handle the problem + } + return data; + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/ScriptFlag.java b/src/main/java/io/github/turtleisaac/variabletracker/ScriptFlag.java new file mode 100644 index 0000000..a70aa2c --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/ScriptFlag.java @@ -0,0 +1,49 @@ +package io.github.turtleisaac.variabletracker; + +public class ScriptFlag +{ + private int flagID; + private String flagName; + private String flagDescription; + + public ScriptFlag(int flagID) + { + this.flagID = flagID; + } + + public ScriptFlag(int flagID, String flagName) + { + this.flagID = flagID; + this.flagName = flagName; + } + + public int getFlagID() + { + return flagID; + } + + public void setFlagID(int flagID) + { + this.flagID = flagID; + } + + public String getFlagName() + { + return flagName; + } + + public void setFlagName(String flagName) + { + this.flagName = flagName; + } + + public String getFlagDescription() + { + return flagDescription; + } + + public void setFlagDescription(String flagDescription) + { + this.flagDescription = flagDescription; + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/ScriptVariable.java b/src/main/java/io/github/turtleisaac/variabletracker/ScriptVariable.java new file mode 100644 index 0000000..3b09df0 --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/ScriptVariable.java @@ -0,0 +1,149 @@ +package io.github.turtleisaac.variabletracker; + +import java.util.ArrayList; +import java.util.List; + +public class ScriptVariable +{ + private int variableID = -1; + private String variableName = ""; + private String variableDescription = ""; + private boolean temp = false; + + private List variableValues = new ArrayList<>(); + + public ScriptVariable() {} + + public ScriptVariable(int variableID) + { + this.variableID = variableID; + } + + public ScriptVariable(String variableName, int variableID) + { + this.variableName = variableName; + this.variableID = variableID; + } + + public int getVariableID() + { + return variableID; + } + + public void setVariableID(int variableID) + { + this.variableID = variableID; + } + + public String getVariableName() + { + return variableName; + } + + public void setVariableName(String variableName) + { + this.variableName = variableName; + } + + public String getVariableDescription() + { + return variableDescription; + } + + public void setVariableDescription(String variableDescription) + { + this.variableDescription = variableDescription; + } + + public VariableValue createVariableValue() + { + return new VariableValue(); + } + + public VariableValue createVariableValue(int value) + { + return new VariableValue(value); + } + + public List getVariableValues() + { + return variableValues; + } + + public void setVariableValues(List variableValues) + { + this.variableValues = variableValues; + } + + public boolean isNotTemp() + { + return !temp; + } + + public void setTemp(boolean temp) + { + this.temp = temp; + } + + @Override + public String toString() + { + if (variableName.isEmpty()) + { + return String.format("0x%s", Integer.toHexString(variableID).toUpperCase()); + } + return String.format("%s (0x%s)", variableName, Integer.toHexString(variableID).toUpperCase()).trim(); + } + + public static class VariableValue + { + private int value = -1; + private String valueName = ""; + private String valueDescription = ""; + + public VariableValue() {} + + public VariableValue(int value) + { + this.value = value; + } + + public int getValue() + { + return value; + } + + public void setValue(int value) + { + this.value = value; + } + + public String getValueName() + { + return valueName; + } + + public void setValueName(String valueName) + { + this.valueName = valueName; + } + + public String getValueDescription() + { + return valueDescription; + } + + public void setValueDescription(String valueDescription) + { + this.valueDescription = valueDescription; + } + +// @Override +// public String toString() +// { +// String varName = variableName.isEmpty() ? "0x" + Integer.toHexString(variableID).toUpperCase() : variableName; +// String valName = valueName.isEmpty() ? String.valueOf(value) : valueName; +// return String.format("%s.%s (%d)", varName, valName, value); +// } + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/HexadecimalCellRenderer.java b/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/HexadecimalCellRenderer.java new file mode 100644 index 0000000..e43b9d5 --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/HexadecimalCellRenderer.java @@ -0,0 +1,44 @@ +package io.github.turtleisaac.variabletracker.gui.cells; + +import javax.swing.*; +import javax.swing.table.DefaultTableCellRenderer; +import java.awt.*; + +public class HexadecimalCellRenderer extends DefaultTableCellRenderer +{ + public HexadecimalCellRenderer() + { + System.currentTimeMillis(); + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) + { + JLabel label = (JLabel) super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + + int num; + if (value instanceof Integer integer) + num = integer; + else if (value instanceof String str) + { + if (str.startsWith("0x")) + num = Integer.parseInt(str.substring(2), 16); + else + num = Integer.parseInt(str); + } + else + { + JOptionPane.showMessageDialog(this, "An unexpected error occurred with the data contained within"); + throw new RuntimeException("Invalid data type provided"); + } + + label.setText("0x" + Integer.toHexString(num).toUpperCase()); + + if (table.getValueAt(row, 0) instanceof Integer val) + { + setEnabled(!((val >= 0x4000 && val <= 0x401F) || val >= 0x8000)); + } + + return this; + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/HexadecimalSpinner.java b/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/HexadecimalSpinner.java new file mode 100644 index 0000000..2cf932d --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/HexadecimalSpinner.java @@ -0,0 +1,53 @@ +package io.github.turtleisaac.variabletracker.gui.cells; + +import javax.swing.*; +import javax.swing.text.DefaultFormatter; +import javax.swing.text.DefaultFormatterFactory; +import java.text.ParseException; + +public class HexadecimalSpinner extends JSpinner +{ + public HexadecimalSpinner() + { + super(); + configureEditor(); + } + + public HexadecimalSpinner(SpinnerModel model) { + super(model); + configureEditor(); + } + + private void configureEditor() + { + DefaultEditor editor = (DefaultEditor) getEditor(); + JFormattedTextField tf = editor.getTextField(); + tf.setFormatterFactory(new HexFormatterFactory()); + } + + private static class HexFormatterFactory extends DefaultFormatterFactory + { + public JFormattedTextField.AbstractFormatter getDefaultFormatter() { + return new HexFormatter(); + } + } + + private static class HexFormatter extends DefaultFormatter + { + public Object stringToValue(String text) throws ParseException + { + try { + if (text.startsWith("0x")) + return (int) Long.parseLong(text.substring(2), 16); + return (int) Long.parseLong(text); + } catch (NumberFormatException nfe) { + throw new ParseException(text,0); + } + } + + public String valueToString(Object value) + { + return "0x" + Long.toHexString((Integer) value).toUpperCase(); + } + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/NumberCellEditor.java b/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/NumberCellEditor.java new file mode 100644 index 0000000..80aa10e --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/cells/NumberCellEditor.java @@ -0,0 +1,47 @@ +package io.github.turtleisaac.variabletracker.gui.cells; + +import com.formdev.flatlaf.ui.FlatSpinnerUI; + +import javax.swing.*; +import javax.swing.table.TableCellEditor; +import java.awt.*; + +public class NumberCellEditor extends AbstractCellEditor implements TableCellEditor +{ + private final JSpinner spinner; + + public NumberCellEditor(boolean hex) + { + if (hex) + spinner = new HexadecimalSpinner(); + else + spinner = new JSpinner(); + + spinner.setUI(new FlatSpinnerUI() { + protected Component createNextButton() { + return null; + } + + protected Component createPreviousButton() { + return null; + } + }); + } + + @Override + public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) + { + spinner.setValue(value); + if (table.getValueAt(row, 0) instanceof Integer val) + { + spinner.setEnabled(!((val >= 0x4000 && val <= 0x401F) || val >= 0x8000)); + } + return spinner; + } + + @Override + public Object getCellEditorValue() + { + return spinner.getValue(); + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/flags/FlagTracker.java b/src/main/java/io/github/turtleisaac/variabletracker/gui/flags/FlagTracker.java new file mode 100644 index 0000000..171ee29 --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/flags/FlagTracker.java @@ -0,0 +1,136 @@ +/* + * Created by JFormDesigner + */ + +package io.github.turtleisaac.variabletracker.gui.flags; + +import java.util.*; +import javax.swing.*; +import javax.swing.table.*; +import net.miginfocom.swing.*; + +/** + * @author turtleisaac + */ +public class FlagTracker extends JPanel { + public FlagTracker() { + initComponents(); + } + + private void initComponents() { + // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents @formatter:off + // Generated using JFormDesigner non-commercial license + ResourceBundle bundle = ResourceBundle.getBundle("variable_tracker.gui"); + toolBar1 = new JToolBar(); + label1 = new JLabel(); + textField1 = new JTextField(); + button2 = new JButton(); + scrollPane1 = new JScrollPane(); + table1 = new JTable(); + vSpacer1 = new JPanel(null); + menuBar1 = new JMenuBar(); + fileMenu = new JMenu(); + openMenuItem = new JMenuItem(); + saveMenuItem = new JMenuItem(); + editMenu = new JMenu(); + menu3 = new JMenu(); + menuItem1 = new JMenuItem(); + + //======== this ======== + setLayout(new MigLayout( + "hidemode 3", + // columns + "[grow,fill]", + // rows + "[]" + + "[grow,fill]" + + "[]")); + + //======== toolBar1 ======== + { + toolBar1.setFloatable(false); + + //---- label1 ---- + label1.setText(bundle.getString("VariableTracker.searchLabel.text")); + toolBar1.add(label1); + toolBar1.add(textField1); + toolBar1.addSeparator(); + + //---- button2 ---- + button2.setText(bundle.getString("VariableTracker.optionsButton.text")); + toolBar1.add(button2); + } + add(toolBar1, "north"); + + //======== scrollPane1 ======== + { + + //---- table1 ---- + table1.setModel(new DefaultTableModel( + new Object[][] { + {null, null, null}, + {null, null, null}, + }, + new String[] { + "Flag", "Name", "Description" + } + )); + scrollPane1.setViewportView(table1); + } + add(scrollPane1, "cell 0 1"); + add(vSpacer1, "cell 0 2"); + + //======== menuBar1 ======== + { + + //======== fileMenu ======== + { + fileMenu.setText(bundle.getString("VariableTracker.fileMenu.text")); + + //---- openMenuItem ---- + openMenuItem.setText(bundle.getString("VariableTracker.openMenuItem.text")); + fileMenu.add(openMenuItem); + + //---- saveMenuItem ---- + saveMenuItem.setText(bundle.getString("VariableTracker.saveMenuItem.text")); + fileMenu.add(saveMenuItem); + } + menuBar1.add(fileMenu); + + //======== editMenu ======== + { + editMenu.setText(bundle.getString("VariableTracker.editMenu.text")); + } + menuBar1.add(editMenu); + + //======== menu3 ======== + { + menu3.setText(bundle.getString("VariableTracker.helpMenu.text")); + + //---- menuItem1 ---- + menuItem1.setText(bundle.getString("VariableTracker.infoMenuItem.text")); + menu3.add(menuItem1); + } + menuBar1.add(menu3); + } + // JFormDesigner - End of component initialization //GEN-END:initComponents @formatter:on + } + + // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables @formatter:off + // Generated using JFormDesigner non-commercial license + private JToolBar toolBar1; + private JLabel label1; + private JTextField textField1; + private JButton button2; + private JScrollPane scrollPane1; + private JTable table1; + private JPanel vSpacer1; + private JMenuBar menuBar1; + private JMenu fileMenu; + private JMenuItem openMenuItem; + private JMenuItem saveMenuItem; + private JMenu editMenu; + private JMenu menu3; + private JMenuItem menuItem1; + // JFormDesigner - End of variables declaration //GEN-END:variables @formatter:on +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/flags/FlagTracker.jfd b/src/main/java/io/github/turtleisaac/variabletracker/gui/flags/FlagTracker.jfd new file mode 100644 index 0000000..fe1e09b --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/flags/FlagTracker.jfd @@ -0,0 +1,110 @@ +JFDML JFormDesigner: "8.0.5.0.268" Java: "17.0.8" encoding: "UTF-8" + +new FormModel { + "i18n.bundlePackage": "variable_tracker" + "i18n.bundleName": "gui" + "i18n.keyPrefix": "FlagTracker" + contentType: "form/swing" + root: new FormRoot { + add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { + "$layoutConstraints": "hidemode 3" + "$columnConstraints": "[grow,fill]" + "$rowConstraints": "[][grow,fill][]" + } ) { + name: "this" + add( new FormContainer( "javax.swing.JToolBar", new FormLayoutManager( class javax.swing.JToolBar ) ) { + name: "toolBar1" + "floatable": false + add( new FormComponent( "javax.swing.JLabel" ) { + name: "label1" + "text": new FormMessage( null, "VariableTracker.searchLabel.text" ) + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "textField1" + } ) + add( new FormComponent( "javax.swing.JToolBar$Separator" ) { + name: "separator1" + } ) + add( new FormComponent( "javax.swing.JButton" ) { + name: "button2" + "text": new FormMessage( null, "VariableTracker.optionsButton.text" ) + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "north" + } ) + add( new FormContainer( "javax.swing.JScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { + name: "scrollPane1" + add( new FormComponent( "javax.swing.JTable" ) { + name: "table1" + "model": new com.jformdesigner.model.SwingTableModel( new java.util.Vector { + add( new java.util.Vector { + add( null ) + add( null ) + add( null ) + } ) + add( new java.util.Vector { + add( null ) + add( null ) + add( null ) + } ) + }, new java.util.Vector { + add( "Flag" ) + add( "Name" ) + add( "Description" ) + }, new java.util.Vector { + add( null ) + add( null ) + add( null ) + }, new java.util.Vector { + add( null ) + add( null ) + add( null ) + }, new java.util.Vector { + add( null ) + add( null ) + add( null ) + } ) + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 1" + } ) + add( new FormComponent( "com.jformdesigner.designer.wrapper.VSpacer" ) { + name: "vSpacer1" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 2" + } ) + }, new FormLayoutConstraints( null ) { + "location": new java.awt.Point( 0, 0 ) + "size": new java.awt.Dimension( 565, 300 ) + } ) + add( new FormContainer( "javax.swing.JMenuBar", new FormLayoutManager( class javax.swing.JMenuBar ) ) { + name: "menuBar1" + add( new FormContainer( "javax.swing.JMenu", new FormLayoutManager( class javax.swing.JMenu ) ) { + name: "fileMenu" + "text": new FormMessage( null, "VariableTracker.fileMenu.text" ) + add( new FormComponent( "javax.swing.JMenuItem" ) { + name: "openMenuItem" + "text": new FormMessage( null, "VariableTracker.openMenuItem.text" ) + } ) + add( new FormComponent( "javax.swing.JMenuItem" ) { + name: "saveMenuItem" + "text": new FormMessage( null, "VariableTracker.saveMenuItem.text" ) + } ) + } ) + add( new FormContainer( "javax.swing.JMenu", new FormLayoutManager( class javax.swing.JMenu ) ) { + name: "editMenu" + "text": new FormMessage( null, "VariableTracker.editMenu.text" ) + } ) + add( new FormContainer( "javax.swing.JMenu", new FormLayoutManager( class javax.swing.JMenu ) ) { + name: "menu3" + "text": new FormMessage( null, "VariableTracker.helpMenu.text" ) + add( new FormComponent( "javax.swing.JMenuItem" ) { + name: "menuItem1" + "text": new FormMessage( null, "VariableTracker.infoMenuItem.text" ) + } ) + } ) + }, new FormLayoutConstraints( null ) { + "location": new java.awt.Point( 130, 440 ) + } ) + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/variables/VariableTracker.java b/src/main/java/io/github/turtleisaac/variabletracker/gui/variables/VariableTracker.java new file mode 100644 index 0000000..095e7f8 --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/variables/VariableTracker.java @@ -0,0 +1,931 @@ +/* + * Created by JFormDesigner + */ + +package io.github.turtleisaac.variabletracker.gui.variables; + +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.awt.event.*; +import java.util.*; +import javax.swing.border.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import io.github.turtleisaac.variabletracker.ScriptVariable; +import io.github.turtleisaac.variabletracker.gui.cells.HexadecimalCellRenderer; +import io.github.turtleisaac.variabletracker.gui.cells.NumberCellEditor; +import net.miginfocom.swing.*; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.event.ListSelectionEvent; +import javax.swing.table.*; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * @author turtleisaac + */ +public class VariableTracker extends JPanel +{ + private static final String ADD_VARIABLE_POPUP_MENU_TEXT; + private static final String REMOVE_VARIABLE_POPUP_MENU_TEXT; + private static final String COPY_VARIABLE_NAME_POPUP_MENU_TEXT; + private static final String ADD_VARIABLE_DIALOG_PROMPT_TEXT; + private static final String ADD_VARIABLE_DIALOG_ID_CONFLICT_TEXT; + private static final String ADD_VARIABLE_DIALOG_INVALID_NUMBER_TEXT; + private static final String REMOVE_VARIABLE_ERROR_TEXT; + private static final String HELP_INFO_TEXT; + + static { + ResourceBundle bundle = ResourceBundle.getBundle("variable_tracker.gui"); + + ADD_VARIABLE_POPUP_MENU_TEXT = bundle.getString("variableTable.popUp.addItem.text"); + REMOVE_VARIABLE_POPUP_MENU_TEXT = bundle.getString("variableTable.popUp.removeItem.text"); + COPY_VARIABLE_NAME_POPUP_MENU_TEXT = bundle.getString("variableTable.popUp.copyNameItem.text"); + + ADD_VARIABLE_DIALOG_PROMPT_TEXT = bundle.getString("variableTable.addDialog.prompt.text"); + ADD_VARIABLE_DIALOG_ID_CONFLICT_TEXT = bundle.getString("variableTable.addDialog.idConflict.text"); + ADD_VARIABLE_DIALOG_INVALID_NUMBER_TEXT = bundle.getString("variableTable.addDialog.invalidNumber.text"); + REMOVE_VARIABLE_ERROR_TEXT = bundle.getString("variableTable.removeItem.tempVarRemovalFailure.text"); + HELP_INFO_TEXT = bundle.getString("VariableTracker.infoDialog.text"); + } + + private List variableList; + + private int selectedIndex = 0; + + private List developerDefinedPopupMenuItems = new ArrayList<>(); + + public VariableTracker(String variableListJson) + { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + List variableList; + + try { + variableList = new ArrayList<>(Arrays.asList(objectMapper.readValue(variableListJson, ScriptVariable[].class))); + } + catch(JsonProcessingException e) { + JOptionPane.showMessageDialog(this, e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + throw new RuntimeException(e); + } + + this.variableList = variableList; + prepare(); + } + + public VariableTracker(List variableList) + { + this.variableList = variableList; + prepare(); + } + + public VariableTracker() { + variableList = new ArrayList<>(); + prepare(); + } + + private static final String LOCAL_VAR_PREFIX = "VAR_LOCAL_"; + private static final String TEMP_VAR_PREFIX = "VAR_TEMP_0"; + + private void prepare() + { + ResourceBundle bundle = ResourceBundle.getBundle("variable_tracker.text"); + + for (int i = 0; i < 0x20; i++) + { + ScriptVariable variable = new ScriptVariable(0x4000 + i); + String hex = Integer.toHexString(i); + if (hex.length() == 1) + hex = "0" + hex; + variable.setVariableName(LOCAL_VAR_PREFIX + hex.toUpperCase()); + variable.setVariableDescription(bundle.getString("localVarDescription.text")); + variable.setTemp(true); + variableList.add(variable); + } + + for (int i = 0; i < 0xD; i++) + { + ScriptVariable variable = new ScriptVariable(0x8000 + i); + String hex = Integer.toHexString(i); + variable.setVariableName(TEMP_VAR_PREFIX + hex.toUpperCase()); + variable.setVariableDescription(bundle.getString("tempVarDescription.text")); + + variable.setTemp(true); + variableList.add(variable); + } + +// ScriptVariable variable = new ScriptVariable(0x4020); +// variable.setVariableName("VAR_TUTORIAL_PROGRESS"); +// ScriptVariable.VariableValue variableValue = variable.createVariableValue(0); +// variableValue.setValueName("INCOMPLETE"); +// variable.getVariableValues().add(variableValue); +// variableList.add(variable); +// +// variable = new ScriptVariable(0x4021); +// variable.setVariableName("VAR_THING_2"); +// variableValue = variable.createVariableValue(0); +// variableValue.setValueName("INCOMPLETE"); +// variable.getVariableValues().add(variableValue); +// variableValue = variable.createVariableValue(1); +// variableValue.setValueName("FIRST_STEP"); +// variable.getVariableValues().add(variableValue); +// variableValue = variable.createVariableValue(2); +// variableValue.setValueName("COMPLETE"); +// variable.getVariableValues().add(variableValue); +// variableList.add(variable); +// +// variable = new ScriptVariable(0x4022); +// variable.setVariableName("VAR_THING_3"); +// variableList.add(variable); + + initComponents(); + + ListSelectionModel selectionModel = variableTable.getSelectionModel(); + selectionModel.addListSelectionListener(this::selectedVariableChanged); + + variableTable.setRowSelectionInterval(0, 0); + + variableDescriptionTextArea.getDocument().addDocumentListener(new DocumentListener() + { + private void update() + { + variableList.get(selectedIndex).setVariableDescription(variableDescriptionTextArea.getText()); + } + + @Override + public void insertUpdate(DocumentEvent e) + { + update(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + update(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + update(); + } + }); + + variableTable.getRowSorter().toggleSortOrder(0); + } + + private void selectedVariableChanged(ListSelectionEvent e) + { + if (variableTable.getSelectedRow() != -1) + { + selectedIndex = variableTable.convertRowIndexToModel(variableTable.getSelectedRow()); + ScriptVariable variable = variableList.get(selectedIndex); + updateUI(); + selectedTextField.setText(variable.toString()); + variableDescriptionTextArea.setText(variable.getVariableDescription()); + + variableDescriptionTextArea.setEnabled(variable.isNotTemp()); + variableValuesTable.setEnabled(variable.isNotTemp()); + scrollPane2.setEnabled(variable.isNotTemp()); + + variableValuesTable.updateUI(); + } + else + { + selectedTextField.setText(""); + variableDescriptionTextArea.setText(""); + } + } + + public List getVariableList() + { + return variableList; + } + + public void setVariableList(List variableList) + { + this.variableList = variableList; + ((DefaultTableModel) variableTable.getModel()).fireTableDataChanged(); + } + + public ScriptVariable getSelectedVariable() + { + return variableList.get(selectedIndex); + } + + public JMenuBar getMenuBar() + { + return menuBar; + } + + public void addDeveloperDefinedPopupMenuItem(JMenuItem popupMenuItem) + { + developerDefinedPopupMenuItems.add(popupMenuItem); + } + + public void fireTableDataChanged() + { + ((DefaultTableModel) variableTable.getModel()).fireTableDataChanged(); + ((DefaultTableModel) variableValuesTable.getModel()).fireTableDataChanged(); + } + + private void variableTableMousePressed(MouseEvent e) { +// variableTableMouseAction(e); +// System.out.println("press"); + } + + private void variableTableMouseReleased(MouseEvent e) { + variableTableMouseAction(e); +// System.out.println("release"); + } + + private void variableTableMouseAction(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) { + JPopupMenu menu = new JPopupMenu(); + JMenuItem removeItem = new JMenuItem(REMOVE_VARIABLE_POPUP_MENU_TEXT); + JMenuItem addItem = new JMenuItem(ADD_VARIABLE_POPUP_MENU_TEXT); + JMenuItem copyItem = new JMenuItem(COPY_VARIABLE_NAME_POPUP_MENU_TEXT); + + int r = variableTable.rowAtPoint(e.getPoint()); + if (r >= 0 && r < variableTable.getRowCount()) { + variableTable.setRowSelectionInterval(r, r); + menu.add(removeItem); + } else { + variableTable.clearSelection(); + } + + if (variableTable.isEditing()) + variableTable.getCellEditor().stopCellEditing(); + + int modelIndex = variableTable.convertRowIndexToModel(r); + + removeItem.addActionListener(e12 -> + { + if (!variableList.get(modelIndex).isNotTemp()) + { + JOptionPane.showMessageDialog(this, REMOVE_VARIABLE_ERROR_TEXT, "Error", JOptionPane.ERROR_MESSAGE); + return; + } + variableTable.clearSelection(); + ((DefaultTableModel) variableTable.getModel()).removeRow(modelIndex); + ((DefaultTableModel) variableTable.getModel()).fireTableDataChanged(); + }); + + addItem.addActionListener(e1 -> createNewEntry()); + + copyItem.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + StringSelection selection = new StringSelection(variableList.get(modelIndex).getVariableName()); + Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); + clipboard.setContents(selection, selection); + } + }); + + menu.add(addItem); + developerDefinedPopupMenuItems.forEach(menu::add); + menu.show(variableTable, e.getX(), e.getY()); + } + } + + private void scrollPane1MouseReleased(MouseEvent e) { + if ((e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) && variableList.isEmpty()) { + JPopupMenu menu = new JPopupMenu(); + JMenuItem addItem = new JMenuItem(ADD_VARIABLE_POPUP_MENU_TEXT); + + addItem.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + createNewEntry(); + } + }); + + menu.add(addItem); + menu.show(scrollPane1, e.getX(), e.getY()); + } + } + + private void createNewEntry() + { + String userResponse = JOptionPane.showInputDialog(this, ADD_VARIABLE_DIALOG_PROMPT_TEXT); + if (userResponse == null) + return; + + int num = -1; + try { + if (userResponse.startsWith("0x")) + num = Integer.parseInt(userResponse.substring(2), 16); + else + num = Integer.parseInt(userResponse); + } catch (NumberFormatException exception) { + JOptionPane.showMessageDialog(this, ADD_VARIABLE_DIALOG_INVALID_NUMBER_TEXT, "Error", JOptionPane.ERROR_MESSAGE); + } + + for (ScriptVariable variable : variableList) + { + if (variable.getVariableID() == num) + { + JOptionPane.showMessageDialog(this, ADD_VARIABLE_DIALOG_ID_CONFLICT_TEXT, "Error", JOptionPane.ERROR_MESSAGE); + return; + } + } + + variableList.add(new ScriptVariable(num)); + ((DefaultTableModel) variableTable.getModel()).fireTableDataChanged(); + } + + private void hideTempVarButtonPressed(ActionEvent e) { + TableRowSorter tableModelTableRowSorter = (TableRowSorter) variableTable.getRowSorter(); + + if (hideTempVarButton.isSelected()) + { + if (!variableList.get(selectedIndex).isNotTemp()) + { + variableTable.clearSelection(); + } + + tableModelTableRowSorter.setRowFilter(new RowFilter() + { + @Override + public boolean include(Entry entry) + { + ScriptVariable variable = variableList.get(entry.getIdentifier()); + return variable.isNotTemp(); + } + }); + } + else + { + tableModelTableRowSorter.setRowFilter(null); + } + + } + + private void infoMenuItemPressed(ActionEvent e) { + JOptionPane.showMessageDialog(this, HELP_INFO_TEXT, "Info", JOptionPane.INFORMATION_MESSAGE); + } + + public void postUpdateVariableTableAction() + { + + } + + private void initComponents() { + // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents @formatter:off + // Generated using JFormDesigner non-commercial license + ResourceBundle bundle = ResourceBundle.getBundle("variable_tracker.gui"); + splitPane1 = new JSplitPane(); + panel2 = new JPanel(); + variablesLabel = new JLabel(); + hideTempVarButton = new JRadioButton(); + scrollPane1 = new JScrollPane(); + variableTable = new VariableTable(); + vSpacer1 = new JPanel(null); + panel1 = new JPanel(); + selectedLabel = new JLabel(); + selectedTextField = new JTextField(); + variableDescriptionLabel = new JLabel(); + scrollPane3 = new JScrollPane(); + variableDescriptionTextArea = new JTextArea(); + scrollPane2 = new JScrollPane(); + variableValuesTable = new VariableValueTable(); + vSpacer2 = new JPanel(null); + toolBar1 = new JToolBar(); + searchLabel = new JLabel(); + searchTextField = new JTextField(); + optionsButton = new JButton(); + menuBar = new JMenuBar(); + fileMenu = new JMenu(); + openMenuItem = new JMenuItem(); + saveMenuItem = new JMenuItem(); + editMenu = new JMenu(); + helpMenu = new JMenu(); + infoMenuItem = new JMenuItem(); + + //======== this ======== + setMinimumSize(new Dimension(650, 400)); + setPreferredSize(new Dimension(650, 400)); + setLayout(new MigLayout( + "hidemode 3", + // columns + "[grow,fill]", + // rows + "[grow,fill]")); + + //======== splitPane1 ======== + { + splitPane1.setLastDividerLocation(-1); + splitPane1.setDividerLocation(321); + splitPane1.setBorder(LineBorder.createBlackLineBorder()); + + //======== panel2 ======== + { + panel2.setLayout(new MigLayout( + "insets 0 5 0 5,hidemode 3", + // columns + "[grow,fill]", + // rows + "[]" + + "[grow,fill]" + + "[]")); + + //---- variablesLabel ---- + variablesLabel.setText(bundle.getString("VariableTracker.variablesLabel.text")); + panel2.add(variablesLabel, "cell 0 0"); + + //---- hideTempVarButton ---- + hideTempVarButton.setText(bundle.getString("VariableTracker.hideTempVarButton.text")); + hideTempVarButton.addActionListener(e -> hideTempVarButtonPressed(e)); + panel2.add(hideTempVarButton, "cell 0 0,alignx right,growx 0"); + + //======== scrollPane1 ======== + { + scrollPane1.addMouseListener(new MouseAdapter() { + @Override + public void mouseReleased(MouseEvent e) { + scrollPane1MouseReleased(e); + } + }); + + //---- variableTable ---- + TableModel variableTableModel = variableTable.getModel(); + variableTable.setAutoCreateRowSorter(true); + variableTable.setModel(new DefaultTableModel()); + variableTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + variableTable.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN); + variableTable.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + variableTableMousePressed(e); + } + @Override + public void mouseReleased(MouseEvent e) { + variableTableMouseReleased(e); + } + }); + variableTable.setModel(variableTableModel); + ((VariableTable) variableTable).updateRenderersAndEditors(); + scrollPane1.setViewportView(variableTable); + } + panel2.add(scrollPane1, "cell 0 1"); + panel2.add(vSpacer1, "cell 0 2"); + } + splitPane1.setLeftComponent(panel2); + + //======== panel1 ======== + { + panel1.setLayout(new MigLayout( + "insets 0 5 0 5,hidemode 3", + // columns + "[grow,fill]", + // rows + "[]" + + "[top]" + + "[grow,fill]" + + "[grow,top]" + + "[]")); + + //---- selectedLabel ---- + selectedLabel.setText(bundle.getString("VariableTracker.selectedLabel.text")); + panel1.add(selectedLabel, "cell 0 0,alignx left,growx 0"); + + //---- selectedTextField ---- + selectedTextField.setEditable(false); + selectedTextField.setEnabled(false); + panel1.add(selectedTextField, "cell 0 0"); + + //---- variableDescriptionLabel ---- + variableDescriptionLabel.setText(bundle.getString("VariableTracker.variableDescriptionLabel.text")); + panel1.add(variableDescriptionLabel, "cell 0 1,aligny top,growy 0"); + + //======== scrollPane3 ======== + { + + //---- variableDescriptionTextArea ---- + variableDescriptionTextArea.setWrapStyleWord(true); + variableDescriptionTextArea.setLineWrap(true); + variableDescriptionTextArea.setWrapStyleWord(true); + scrollPane3.setViewportView(variableDescriptionTextArea); + } + panel1.add(scrollPane3, "cell 0 2,growy"); + + //======== scrollPane2 ======== + { + + //---- variableValuesTable ---- + TableModel variableValueTableModel = variableValuesTable.getModel(); + variableValuesTable.setModel(new DefaultTableModel( + new Object[][] { + {null, null, null}, + {null, null, null}, + }, + new String[] { + "Value", "Name", "Description" + } + ) { + Class[] columnTypes = new Class[] { + Integer.class, String.class, String.class + }; + @Override + public Class getColumnClass(int columnIndex) { + return columnTypes[columnIndex]; + } + }); + variableValuesTable.setPreferredScrollableViewportSize(new Dimension(450, 150)); + variableValuesTable.setModel(variableValueTableModel); + ((VariableValueTable) variableValuesTable).updateRenderersAndEditors(); + scrollPane2.setViewportView(variableValuesTable); + } + panel1.add(scrollPane2, "cell 0 3,grow"); + panel1.add(vSpacer2, "cell 0 4"); + } + splitPane1.setRightComponent(panel1); + } + add(splitPane1, "cell 0 0,growx"); + + //======== toolBar1 ======== + { + toolBar1.setFloatable(false); + + //---- searchLabel ---- + searchLabel.setText(bundle.getString("VariableTracker.searchLabel.text")); + toolBar1.add(searchLabel); + toolBar1.add(searchTextField); + toolBar1.addSeparator(); + + //---- optionsButton ---- + optionsButton.setText(bundle.getString("VariableTracker.optionsButton.text")); + toolBar1.add(optionsButton); + } + add(toolBar1, "north"); + + //======== menuBar ======== + { + + //======== fileMenu ======== + { + fileMenu.setText(bundle.getString("VariableTracker.fileMenu.text")); + + //---- openMenuItem ---- + openMenuItem.setText(bundle.getString("VariableTracker.openMenuItem.text")); + fileMenu.add(openMenuItem); + + //---- saveMenuItem ---- + saveMenuItem.setText(bundle.getString("VariableTracker.saveMenuItem.text")); + fileMenu.add(saveMenuItem); + } + menuBar.add(fileMenu); + + //======== editMenu ======== + { + editMenu.setText(bundle.getString("VariableTracker.editMenu.text")); + } + menuBar.add(editMenu); + + //======== helpMenu ======== + { + helpMenu.setText(bundle.getString("VariableTracker.helpMenu.text")); + + //---- infoMenuItem ---- + infoMenuItem.setText(bundle.getString("VariableTracker.infoMenuItem.text")); + infoMenuItem.addActionListener(e -> infoMenuItemPressed(e)); + helpMenu.add(infoMenuItem); + } + menuBar.add(helpMenu); + } + // JFormDesigner - End of component initialization //GEN-END:initComponents @formatter:on + } + + // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables @formatter:off + // Generated using JFormDesigner non-commercial license + private JSplitPane splitPane1; + private JPanel panel2; + private JLabel variablesLabel; + private JRadioButton hideTempVarButton; + private JScrollPane scrollPane1; + private JTable variableTable; + private JPanel vSpacer1; + private JPanel panel1; + private JLabel selectedLabel; + private JTextField selectedTextField; + private JLabel variableDescriptionLabel; + private JScrollPane scrollPane3; + private JTextArea variableDescriptionTextArea; + private JScrollPane scrollPane2; + private JTable variableValuesTable; + private JPanel vSpacer2; + private JToolBar toolBar1; + private JLabel searchLabel; + private JTextField searchTextField; + private JButton optionsButton; + private JMenuBar menuBar; + private JMenu fileMenu; + private JMenuItem openMenuItem; + private JMenuItem saveMenuItem; + private JMenu editMenu; + private JMenu helpMenu; + private JMenuItem infoMenuItem; + // JFormDesigner - End of variables declaration //GEN-END:variables @formatter:on + + private class VariableTable extends JTable + { + VariableTable() + { + super(new VariableTableModel()); + getTableHeader().setReorderingAllowed(false); + } + + @Override + public String getToolTipText(MouseEvent event) + { + int rowIdx = rowAtPoint(event.getPoint()); + int modelIdx = convertRowIndexToModel(rowIdx); + + int colIdx = convertColumnIndexToModel(columnAtPoint(event.getPoint())); + + ScriptVariable variable = variableList.get(modelIdx); + return switch (colIdx) + { + case 0 -> "0x" + Integer.toHexString(variable.getVariableID()).toUpperCase(); + case 1 -> String.valueOf(variable.getVariableID()); + case 2 -> variable.getVariableName(); + default -> throw new RuntimeException("Error, invalid column"); + }; + } + + private final int[] widths = new int[] {60, 75}; + + void updateRenderersAndEditors() + { + DefaultTableCellRenderer renderer = new DefaultTableCellRenderer() { + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) + { + super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + if (table.getValueAt(row, 0) instanceof Integer val) + { + setEnabled(!((val >= 0x4000 && val <= 0x401F) || val >= 0x8000)); + } + return this; + } + }; + + setDefaultRenderer(Object.class, renderer); + setDefaultRenderer(Integer.class, renderer); + + for (int i = 0; i < VariableTableModel.NUM_COLUMNS; i++) + { + TableColumn col = getColumnModel().getColumn(i); + + if (i == 0) + { + col.setCellEditor(new NumberCellEditor(true)); + col.setCellRenderer(new HexadecimalCellRenderer()); + col.setMaxWidth(widths[i]); + } + else if (i == 1) + { + col.setCellEditor(new NumberCellEditor(false)); + col.setMaxWidth(widths[i]); + } + } + } + } + + private class VariableTableModel extends DefaultTableModel + { + List headerStrings = new ArrayList<>(); + + public VariableTableModel() + { + super(); + ResourceBundle bundle = ResourceBundle.getBundle("variable_tracker.gui"); + + headerStrings.add(bundle.getString("VariableTracker.variableTable.varHex")); + headerStrings.add(bundle.getString("VariableTracker.variableTable.varDecimal")); + headerStrings.add(bundle.getString("VariableTracker.variableTable.varName")); + } + + @Override + public boolean isCellEditable(int row, int column) + { + return variableList.get(row).isNotTemp(); + } + + @Override + public int getRowCount() + { + return variableList.size(); + } + + @Override + public void removeRow(int row) + { + super.removeRow(row); + variableList.remove(row); + } + + @Override + public Class getColumnClass(int columnIndex) { + if (variableList.isEmpty()) { + return Object.class; + } + return getValueAt(0, columnIndex).getClass(); + } + + private static final int NUM_COLUMNS = 3; + + @Override + public int getColumnCount() + { + return NUM_COLUMNS; + } + + @Override + public Object getValueAt(int row, int column) + { + if (row >= variableList.size()) + return null; + + ScriptVariable variable = variableList.get(row); + + if (column == 0 || column == 1) + { + return variable.getVariableID(); + } + else + { + return variable.getVariableName(); + } + } + + @Override + public void setValueAt(Object aValue, int row, int column) + { + ScriptVariable variable = variableList.get(row); + + if (column == 0 || column == 1) + { + if (aValue instanceof String str) + aValue = Integer.parseInt(str); + + int val = (int) aValue; + + for (ScriptVariable other : variableList) + { + if (variable != other && other.getVariableID() == val) + { + JOptionPane.showMessageDialog(variableTable, "The specified variable value is already in use: \"" + other.getVariableName() + "\".\nAction aborted.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + } + variable.setVariableID((Integer) aValue); + } + else + { + String name = (String) aValue; + + if (name.toLowerCase().startsWith("0x")) + { + JOptionPane.showMessageDialog(variableTable, "You may not use a name which starts with \"0x\".\nAction aborted.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + for (ScriptVariable other : variableList) + { + if (variable != other && other.getVariableName().equalsIgnoreCase(name)) + { + JOptionPane.showMessageDialog(variableTable, "The specified variable name is already in use: \"" + other.getVariableName() + "\".\nAction aborted.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + } + + variable.setVariableName(name); + } + + postUpdateVariableTableAction(); + } + + @Override + public String getColumnName(int column) + { + return headerStrings.get(column); + } + } + + private class VariableValueTable extends JTable + { + VariableValueTable() + { + super(new VariableValueTableModel()); + getTableHeader().setReorderingAllowed(false); + } + + void updateRenderersAndEditors() + { + for (int i = 0; i < VariableValueTableModel.NUM_COLUMNS; i++) + { + TableColumn col = getColumnModel().getColumn(i); + + if (i == 0) + { + col.setCellEditor(new NumberCellEditor(false)); + col.setMaxWidth(50); + } + } + } + + @Override + public String getToolTipText(MouseEvent event) + { + int rowIdx = rowAtPoint(event.getPoint()); + int modelIdx = convertRowIndexToModel(rowIdx); + + int colIdx = convertColumnIndexToModel(columnAtPoint(event.getPoint())); + + ScriptVariable.VariableValue variableValue = variableList.get(selectedIndex).getVariableValues().get(modelIdx); + return switch (colIdx) + { + case 0 -> String.valueOf(variableValue.getValue()); + case 1 -> variableValue.getValueName(); + case 2 -> variableValue.getValueDescription(); + default -> throw new RuntimeException("Error, invalid column"); + }; + } + } + + private class VariableValueTableModel extends DefaultTableModel + { + List headerStrings = new ArrayList<>(); + + public VariableValueTableModel() + { + super(); + + ResourceBundle bundle = ResourceBundle.getBundle("variable_tracker.gui"); + headerStrings.add(bundle.getString("VariableTracker.variableValuesTable.value")); + headerStrings.add(bundle.getString("VariableTracker.variableValuesTable.valueName")); + headerStrings.add(bundle.getString("VariableTracker.variableValuesTable.valueDescription")); + } + + @Override + public int getRowCount() + { + return variableList.get(selectedIndex).getVariableValues().size(); + } + + private static final int NUM_COLUMNS = 3; + + @Override + public int getColumnCount() + { + return NUM_COLUMNS; + } + + @Override + public Object getValueAt(int row, int column) + { + ScriptVariable variable = variableList.get(selectedIndex); + ScriptVariable.VariableValue variableValue = variable.getVariableValues().get(row); + + return switch (column) { + case 0 -> variableValue.getValue(); + case 1 -> variableValue.getValueName(); + case 2 -> variableValue.getValueDescription(); + default -> throw new RuntimeException("Invalid table column index"); + }; + } + + @Override + public void setValueAt(Object aValue, int row, int column) + { + ScriptVariable variable = variableList.get(selectedIndex); + ScriptVariable.VariableValue variableValue = variable.getVariableValues().get(row); + + switch (column) { + case 0 -> variableValue.setValue((Integer) aValue); + case 1 -> variableValue.setValueName((String) aValue); + case 2 -> variableValue.setValueDescription((String) aValue); + default -> throw new RuntimeException("Invalid table column index"); + }; + } + + @Override + public String getColumnName(int column) + { + return headerStrings.get(column); + } + } +} diff --git a/src/main/java/io/github/turtleisaac/variabletracker/gui/variables/VariableTracker.jfd b/src/main/java/io/github/turtleisaac/variabletracker/gui/variables/VariableTracker.jfd new file mode 100644 index 0000000..29df24e --- /dev/null +++ b/src/main/java/io/github/turtleisaac/variabletracker/gui/variables/VariableTracker.jfd @@ -0,0 +1,215 @@ +JFDML JFormDesigner: "8.0.5.0.268" Java: "17.0.8" encoding: "UTF-8" + +new FormModel { + "i18n.bundlePackage": "variable_tracker" + "i18n.bundleName": "gui" + "i18n.autoExternalize": true + "i18n.keyPrefix": "VariableTracker" + contentType: "form/swing" + root: new FormRoot { + add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { + "$layoutConstraints": "hidemode 3" + "$columnConstraints": "[grow,fill]" + "$rowConstraints": "[grow,fill]" + } ) { + name: "this" + "minimumSize": new java.awt.Dimension( 650, 400 ) + "preferredSize": new java.awt.Dimension( 650, 400 ) + add( new FormContainer( "javax.swing.JSplitPane", new FormLayoutManager( class javax.swing.JSplitPane ) ) { + name: "splitPane1" + "lastDividerLocation": -1 + "dividerLocation": 321 + "border": new javax.swing.border.LineBorder( sfield java.awt.Color black, 1, false ) + add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { + "$layoutConstraints": "insets 0 5 0 5,hidemode 3" + "$columnConstraints": "[grow,fill]" + "$rowConstraints": "[][grow,fill][]" + } ) { + name: "panel2" + add( new FormComponent( "javax.swing.JLabel" ) { + name: "variablesLabel" + "text": new FormMessage( null, "VariableTracker.variablesLabel.text" ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0" + } ) + add( new FormComponent( "javax.swing.JRadioButton" ) { + name: "hideTempVarButton" + "text": new FormMessage( null, "VariableTracker.hideTempVarButton.text" ) + addEvent( new FormEvent( "java.awt.event.ActionListener", "actionPerformed", "hideTempVarButtonPressed", true ) ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0,alignx right,growx 0" + } ) + add( new FormContainer( "javax.swing.JScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { + name: "scrollPane1" + addEvent( new FormEvent( "java.awt.event.MouseListener", "mouseReleased", "scrollPane1MouseReleased", true ) ) + add( new FormComponent( "javax.swing.JTable" ) { + name: "variableTable" + "autoCreateRowSorter": true + "model": new com.jformdesigner.model.SwingTableModel( new java.util.Vector, new java.util.Vector, new java.util.Vector, new java.util.Vector, new java.util.Vector ) + "selectionMode": 0 + "autoResizeMode": 3 + auxiliary() { + "JavaCodeGenerator.customCreateCode": "new VariableTable();" + "JavaCodeGenerator.preInitCode": "TableModel variableTableModel = variableTable.getModel();" + "JavaCodeGenerator.postInitCode": "variableTable.setModel(variableTableModel);\n((VariableTable) variableTable).updateRenderersAndEditors();" + } + addEvent( new FormEvent( "java.awt.event.MouseListener", "mousePressed", "variableTableMousePressed", true ) ) + addEvent( new FormEvent( "java.awt.event.MouseListener", "mouseReleased", "variableTableMouseReleased", true ) ) + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 1" + } ) + add( new FormComponent( "com.jformdesigner.designer.wrapper.VSpacer" ) { + name: "vSpacer1" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 2" + } ) + }, new FormLayoutConstraints( class java.lang.String ) { + "value": "left" + } ) + add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { + "$layoutConstraints": "insets 0 5 0 5,hidemode 3" + "$columnConstraints": "[grow,fill]" + "$rowConstraints": "[][top][grow,fill][grow,top][]" + } ) { + name: "panel1" + add( new FormComponent( "javax.swing.JLabel" ) { + name: "selectedLabel" + "text": new FormMessage( null, "VariableTracker.selectedLabel.text" ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0,alignx left,growx 0" + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "selectedTextField" + "editable": false + "enabled": false + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0" + } ) + add( new FormComponent( "javax.swing.JLabel" ) { + name: "variableDescriptionLabel" + "text": new FormMessage( null, "VariableTracker.variableDescriptionLabel.text" ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 1,aligny top,growy 0" + } ) + add( new FormContainer( "javax.swing.JScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { + name: "scrollPane3" + add( new FormComponent( "javax.swing.JTextArea" ) { + name: "variableDescriptionTextArea" + "wrapStyleWord": true + auxiliary() { + "JavaCodeGenerator.postInitCode": "variableDescriptionTextArea.setLineWrap(true);\nvariableDescriptionTextArea.setWrapStyleWord(true);" + } + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 2,growy" + } ) + add( new FormContainer( "javax.swing.JScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { + name: "scrollPane2" + add( new FormComponent( "javax.swing.JTable" ) { + name: "variableValuesTable" + "model": new com.jformdesigner.model.SwingTableModel( new java.util.Vector { + add( new java.util.Vector { + add( null ) + add( null ) + add( null ) + } ) + add( new java.util.Vector { + add( null ) + add( null ) + add( null ) + } ) + }, new java.util.Vector { + add( "Value" ) + add( "Name" ) + add( "Description" ) + }, new java.util.Vector { + add( class java.lang.Integer ) + add( class java.lang.String ) + add( class java.lang.String ) + }, new java.util.Vector { + add( null ) + add( null ) + add( null ) + }, new java.util.Vector { + add( null ) + add( null ) + add( null ) + } ) + "preferredScrollableViewportSize": new java.awt.Dimension( 450, 150 ) + auxiliary() { + "JavaCodeGenerator.customCreateCode": "new VariableValueTable();" + "JavaCodeGenerator.preInitCode": "TableModel variableValueTableModel = variableValuesTable.getModel();" + "JavaCodeGenerator.postInitCode": "variableValuesTable.setModel(variableValueTableModel);\n((VariableValueTable) variableValuesTable).updateRenderersAndEditors();" + } + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 3,grow" + } ) + add( new FormComponent( "com.jformdesigner.designer.wrapper.VSpacer" ) { + name: "vSpacer2" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 4" + } ) + }, new FormLayoutConstraints( class java.lang.String ) { + "value": "right" + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0,growx" + } ) + add( new FormContainer( "javax.swing.JToolBar", new FormLayoutManager( class javax.swing.JToolBar ) ) { + name: "toolBar1" + "floatable": false + add( new FormComponent( "javax.swing.JLabel" ) { + name: "searchLabel" + "text": new FormMessage( null, "VariableTracker.searchLabel.text" ) + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "searchTextField" + } ) + add( new FormComponent( "javax.swing.JToolBar$Separator" ) { + name: "separator1" + } ) + add( new FormComponent( "javax.swing.JButton" ) { + name: "optionsButton" + "text": new FormMessage( null, "VariableTracker.optionsButton.text" ) + } ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "north" + } ) + }, new FormLayoutConstraints( null ) { + "location": new java.awt.Point( 0, 0 ) + "size": new java.awt.Dimension( 650, 400 ) + } ) + add( new FormContainer( "javax.swing.JMenuBar", new FormLayoutManager( class javax.swing.JMenuBar ) ) { + name: "menuBar" + add( new FormContainer( "javax.swing.JMenu", new FormLayoutManager( class javax.swing.JMenu ) ) { + name: "fileMenu" + "text": new FormMessage( null, "VariableTracker.fileMenu.text" ) + add( new FormComponent( "javax.swing.JMenuItem" ) { + name: "openMenuItem" + "text": new FormMessage( null, "VariableTracker.openMenuItem.text" ) + } ) + add( new FormComponent( "javax.swing.JMenuItem" ) { + name: "saveMenuItem" + "text": new FormMessage( null, "VariableTracker.saveMenuItem.text" ) + } ) + } ) + add( new FormContainer( "javax.swing.JMenu", new FormLayoutManager( class javax.swing.JMenu ) ) { + name: "editMenu" + "text": new FormMessage( null, "VariableTracker.editMenu.text" ) + } ) + add( new FormContainer( "javax.swing.JMenu", new FormLayoutManager( class javax.swing.JMenu ) ) { + name: "helpMenu" + "text": new FormMessage( null, "VariableTracker.helpMenu.text" ) + add( new FormComponent( "javax.swing.JMenuItem" ) { + name: "infoMenuItem" + "text": new FormMessage( null, "VariableTracker.infoMenuItem.text" ) + addEvent( new FormEvent( "java.awt.event.ActionListener", "actionPerformed", "infoMenuItemPressed", true ) ) + } ) + } ) + }, new FormLayoutConstraints( null ) { + "location": new java.awt.Point( 45, 455 ) + } ) + } +} diff --git a/src/main/resources/variable_tracker/gui.properties b/src/main/resources/variable_tracker/gui.properties new file mode 100644 index 0000000..2423648 --- /dev/null +++ b/src/main/resources/variable_tracker/gui.properties @@ -0,0 +1,33 @@ + +VariableTracker.fileMenu.text=File +VariableTracker.editMenu.text=Edit +VariableTracker.helpMenu.text=Help +VariableTracker.infoMenuItem.text=Info +VariableTracker.openMenuItem.text=Open +VariableTracker.saveMenuItem.text=Save +VariableTracker.optionsButton.text=Options +VariableTracker.hideTempVarButton.text=Hide Temp Vars +VariableTracker.searchLabel.text=Search\: +VariableTracker.selectedLabel.text=Selected\: +VariableTracker.variablesLabel.text=Variables +VariableTracker.variableDescriptionLabel.text=Variable Description +VariableTracker.variableTable.varHex=Hex +VariableTracker.variableTable.varDecimal=Decimal +VariableTracker.variableTable.varName=Name + +VariableTracker.variableValuesTable.value=Value +VariableTracker.variableValuesTable.valueName=Name +VariableTracker.variableValuesTable.valueDescription=Description + +variableTable.popUp.removeItem.text=Remove entry +variableTable.popUp.addItem.text=Add new entry +variableTable.popUp.copyNameItem.text=Copy variable name + +variableTable.addDialog.prompt.text=What is the variable value?\n(You may use hex or decimal). +variableTable.addDialog.idConflict.text=An entry for this variable already exists. Action aborted. +variableTable.addDialog.invalidNumber.text=This is not a valid number. Action aborted. +errorTitle.text=Error + +variableTable.removeItem.tempVarRemovalFailure.text=This variable cannot be removed. + +VariableTracker.infoDialog.text=Variables are used to track progression for scripts and triggers, and also are used as temporary storage in memory during script execution.\n\nVariables in the 0x4000-0x401F range are temporary local variables that reset upon leaving the current header/map.\n\nVariables in the 0x8000 to 0x800C range are temporary and reset upon the end of the current script.\n\nYou can freely use variables from 0x4020 through 0x411F (I think). \ No newline at end of file diff --git a/src/main/resources/variable_tracker/text.properties b/src/main/resources/variable_tracker/text.properties new file mode 100644 index 0000000..3079dab --- /dev/null +++ b/src/main/resources/variable_tracker/text.properties @@ -0,0 +1,2 @@ +localVarDescription.text=A local variable which resets upon leaving the current header. +tempVarDescription.text=A temporary variable which resets upon the end of the current script. \ No newline at end of file diff --git a/src/test/java/io/github/turtleisaac/pokeditor/DataManagerCacheTest.java b/src/test/java/io/github/turtleisaac/pokeditor/DataManagerCacheTest.java new file mode 100644 index 0000000..6e4a332 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/DataManagerCacheTest.java @@ -0,0 +1,150 @@ +package io.github.turtleisaac.pokeditor; + +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.formats.moves.MoveData; +import io.github.turtleisaac.pokeditor.formats.personal.PersonalData; +import io.github.turtleisaac.pokeditor.gamedata.GameCodeBinaries; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A missing ROM must never cost the user their work. + *

+ * The parsed-data caches had no tests. That is how a change to scope them per-ROM shipped a + * regression in which one call passing a null ROM was read as "a different ROM": it discarded + * every open sheet's parsed data, every unsaved-change flag, and the code binaries - + * and since the binaries are populated exactly once at startup, the session could never save + * again. Everything below is about that: not the exception, but what survives it. + *

+ * The ROM-switching property itself is deliberately absent. Exercising it needs two + * {@link io.github.turtleisaac.nds4j.NintendoDsRom} instances, which cannot be built without + * two real ROM files, and a fixture assembled to look like one would only prove the cache + * agrees with the fixture. It is also unreachable in the shipped application - the three menu + * entries that would open a second ROM are unimplemented, and closing the tool frame ends the + * process - so the scoping is a guard against a future capability, not a tested behaviour. + * Saying so is more useful than a test that pretends otherwise. + */ +class DataManagerCacheTest +{ + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @SuppressWarnings("unchecked") + private static T field(String name) + { + try { + Field f = DataManager.class.getDeclaredField(name); + f.setAccessible(true); + return (T) f.get(null); + } + catch (ReflectiveOperationException e) { + throw new AssertionError("DataManager." + name + " could not be read", e); + } + } + + private static Map, Object> dataMap() { return field("dataMap"); } + private static Map codeBinaries() { return field("codeBinaries"); } + private static Set> dirtyClasses() { return field("dirtyClasses"); } + + /** + * Puts the caches into the state a running session is in: sheets parsed, one of them edited, + * the arm9 binary loaded. Reflection because there is no reset hook - itself worth noting, + * since state with process lifetime and no way to clear it is hard to test deliberately. + */ + @BeforeEach + void seedCaches() + { + dataMap().clear(); + codeBinaries().clear(); + dirtyClasses().clear(); + + dataMap().put(PersonalData.class, new ArrayList<>()); + dataMap().put(MoveData.class, new ArrayList<>()); + dirtyClasses().add(PersonalData.class); + codeBinaries().put(GameCodeBinaries.ARM9, new Object()); + } + + @Test + @DisplayName("a null ROM is refused before any cache is touched") + void nullRomIsRefusedWithoutSideEffects() + { + // The exception is not the point; what survives it is. A missing ROM is a caller + // mistake, and the cost of one must not be the user's unsaved work. Nor may it be + // answered from whatever happens to be cached - that quietly returns another ROM's + // data, which is the very failure the per-ROM scoping exists to prevent. + assertThatThrownBy(() -> DataManager.getData(null, MoveData.class)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("ROM"); + + assertThat(dataMap()).as("a refused call must not discard parsed data") + .containsKeys(PersonalData.class, MoveData.class); + assertThat(codeBinaries()).as("a refused call must not discard the code binaries - " + + "nothing repopulates them after startup, so losing them ends the session") + .isNotEmpty(); + assertThat(dirtyClasses()).as("a refused call must not discard unsaved-change flags") + .contains(PersonalData.class); + } + + @Test + @DisplayName("every entry point taking a ROM refuses null the same way") + void everyRomEntryPointRefusesNull() + { + // one guarded and the rest not is the same bug with a smaller blast radius, and the + // unguarded one is the one a future caller will reach for + assertThatThrownBy(() -> DataManager.getData(null, MoveData.class)) + .as("getData").isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> DataManager.prepareData(null, MoveData.class)) + .as("prepareData").isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> DataManager.resetData(null, MoveData.class)) + .as("resetData").isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> DataManager.codeBinarySetup(null)) + .as("codeBinarySetup").isInstanceOf(NullPointerException.class); + + assertThat(dataMap()).containsKeys(PersonalData.class, MoveData.class); + assertThat(codeBinaries()).isNotEmpty(); + assertThat(dirtyClasses()).contains(PersonalData.class); + } + + @Test + @DisplayName("the files a save writes can be listed without preparing anything") + void theFileListIsAvailableWithoutSideEffects() + { + // The save confirmation names the files it is about to write. It used to get that list + // by preparing the data first - and preparing writes the TM table straight into the + // shared arm9 buffer, so declining the confirmation left the ROM already modified. + // Asking the parser what it writes has no side effects, which is what lets both + // confirmations happen before anything is touched. + assertThat(DataManager.filesWrittenBy(PersonalData.class)) + .as("a parser must be able to name its outputs without producing them") + .isNotEmpty(); + + assertThat(dataMap()).containsKeys(PersonalData.class, MoveData.class); + assertThat(dirtyClasses()).contains(PersonalData.class); + } + + @Test + @DisplayName("isLoaded answers without parsing") + void isLoadedIsAQuery() + { + assertThat(DataManager.isLoaded(PersonalData.class)).isTrue(); + + dataMap().remove(PersonalData.class); + assertThat(DataManager.isLoaded(PersonalData.class)).isFalse(); + + // asking must not have gone and fetched it + assertThat(dataMap()).doesNotContainKey(PersonalData.class); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/DataManagerDirtyStateTest.java b/src/test/java/io/github/turtleisaac/pokeditor/DataManagerDirtyStateTest.java new file mode 100644 index 0000000..85b33d7 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/DataManagerDirtyStateTest.java @@ -0,0 +1,251 @@ +package io.github.turtleisaac.pokeditor; + +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.formats.evolutions.EvolutionData; +import io.github.turtleisaac.pokeditor.formats.learnsets.LearnsetData; +import io.github.turtleisaac.pokeditor.formats.moves.MoveData; +import io.github.turtleisaac.pokeditor.formats.personal.PersonalData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Property-based tests for {@link DataManager}'s dirty-tracking subsystem. + * + *

THEORY. Dirty tracking is a finite set with two commands and one query: + * {@code markDirty} is set insertion, {@code markClean} is set removal, {@code hasUnsavedChanges} + * is the emptiness test. The laws follow from set algebra: + *

    + *
  • Idempotence. {@code S u {x} u {x} == S u {x}} - marking twice is marking once, so + * one {@code markClean} always suffices. A counter-based implementation would violate this + * and strand data as permanently dirty.
  • + *
  • Locality. Inserting or removing x leaves the membership of every y != x + * unchanged. Losing this is the data-loss bug: a type the user edited stops being reported + * because an unrelated type was saved.
  • + *
  • Command/query separation. The query is pure: asking whether there are unsaved + * changes cannot clear them. This is the same law the learnsets defect broke.
  • + *
+ * + *

NOTE ON TESTABILITY. The state is {@code private static final} with no public reset, so the + * suite has to clear it reflectively in {@code @BeforeEach}; and there is no per-class query, so + * membership of a single class is established behaviourally (mark it, clean everything else, ask). + */ +public class DataManagerDirtyStateTest +{ + private static final Class A = PersonalData.class; + private static final Class B = LearnsetData.class; + private static final Class C = MoveData.class; + private static final Class D = EvolutionData.class; + + private static final List> ALL = List.of(A, B, C, D); + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @BeforeEach + @SuppressWarnings("unchecked") + void resetStaticState() throws Exception + { + Field field = DataManager.class.getDeclaredField("dirtyClasses"); + field.setAccessible(true); + ((Set>) field.get(null)).clear(); + // Precondition for every test below: the shared static state starts empty. + assertThat(DataManager.hasUnsavedChanges()).isFalse(); + } + + /** + * Behavioural membership test, since no per-class query exists: temporarily clean every other + * known class and ask the global query, then restore the set exactly as it was found. + */ + private static boolean isDirty(Class target) + { + Set> snapshot = new HashSet<>(currentSet()); + for (Class other : ALL) + { + if (other != target) + DataManager.markClean(other); + } + boolean result = DataManager.hasUnsavedChanges(); + for (Class restored : snapshot) + DataManager.markDirty(restored); + return result; + } + + @SuppressWarnings("unchecked") + private static Set> currentSet() + { + try + { + Field field = DataManager.class.getDeclaredField("dirtyClasses"); + field.setAccessible(true); + return (Set>) field.get(null); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("dirty-tracking state is not reachable for verification", e); + } + } + + @Test + @DisplayName("marking a type makes it dirty; the empty set reports no unsaved changes") + void markingMakesDirty() + { + assertThat(DataManager.hasUnsavedChanges()).isFalse(); + DataManager.markDirty(A); + // Insertion into an empty set makes it non-empty: the user is now at risk of losing work. + assertThat(DataManager.hasUnsavedChanges()).isTrue(); + assertThat(isDirty(A)).isTrue(); + } + + @Test + @DisplayName("marking one type does not mark another (locality of insertion)") + void markingIsLocal() + { + DataManager.markDirty(A); + // Set insertion touches exactly one element; B was never edited, so it must not be dirty. + assertThat(isDirty(B)).isFalse(); + assertThat(isDirty(C)).isFalse(); + assertThat(isDirty(A)).isTrue(); + } + + @Test + @DisplayName("marking is idempotent: two marks need only one clean") + void markingIsIdempotent() + { + DataManager.markDirty(A); + DataManager.markDirty(A); + DataManager.markDirty(A); + DataManager.markClean(A); + // S u {x} u {x} == S u {x}: a counting implementation would leave A dirty forever, so the + // user would be prompted about changes that no longer exist and could never clear them. + assertThat(DataManager.hasUnsavedChanges()).isFalse(); + } + + @Test + @DisplayName("cleaning one type clears only that type (locality of removal)") + void cleaningIsLocal() + { + DataManager.markDirty(A); + DataManager.markDirty(B); + + DataManager.markClean(A); + // Removing A may not remove B: silently forgetting B is exactly how an edited file gets + // dropped without a save prompt. + assertThat(DataManager.hasUnsavedChanges()).isTrue(); + assertThat(isDirty(B)).isTrue(); + assertThat(isDirty(A)).isFalse(); + + DataManager.markClean(B); + assertThat(DataManager.hasUnsavedChanges()).isFalse(); + } + + @Test + @DisplayName("cleaning a type that was never dirty is a no-op, not a reset") + void cleaningAnUnmarkedTypeIsANoOp() + { + DataManager.markDirty(A); + DataManager.markClean(B); + DataManager.markClean(C); + // Removing an absent element from a set leaves the set unchanged. + assertThat(isDirty(A)).isTrue(); + } + + @Test + @DisplayName("the query is pure: asking about unsaved changes never clears them") + void queryIsPure() + { + DataManager.markDirty(A); + for (int i = 0; i < 5; i++) + assertThat(DataManager.hasUnsavedChanges()).as("query #%d", i).isTrue(); + // Command/query separation: repeated observation is stable, so a confirmation dialog that + // asks twice cannot lose the answer between the two calls. + assertThat(isDirty(A)).isTrue(); + } + + @Test + @DisplayName("a marked type stays dirty until it is explicitly cleaned - the safety property") + void markedTypesSurviveEveryOtherOperation() + { + DataManager.markDirty(A); + + DataManager.markDirty(B); + DataManager.markDirty(B); + DataManager.markClean(B); + DataManager.markClean(C); + DataManager.markDirty(null); + DataManager.hasUnsavedChanges(); + DataManager.markDirty(D); + DataManager.markClean(D); + + // No read, no query and no operation on another type may clear A: the whole purpose of the + // subsystem is that an edit the user made is never silently dropped on save/exit. + assertThat(isDirty(A)).isTrue(); + assertThat(DataManager.hasUnsavedChanges()).isTrue(); + } + + @Test + @DisplayName("markDirty(null) is a no-op and does not corrupt the set") + void nullIsANoOp() + { + // The implementation documents null as ignored; the requirement either way is that it + // cannot poison the set - a null member would make every later query unreliable. + assertThatCode(() -> DataManager.markDirty(null)).doesNotThrowAnyException(); + assertThat(DataManager.hasUnsavedChanges()).isFalse(); + + DataManager.markDirty(A); + DataManager.markDirty(null); + assertThatCode(() -> DataManager.markClean(null)).doesNotThrowAnyException(); + assertThat(isDirty(A)).isTrue(); + DataManager.markClean(A); + assertThat(DataManager.hasUnsavedChanges()).isFalse(); + } + + @Test + @DisplayName("model check: the tracker agrees with a plain HashSet after every operation") + void agreesWithASetModelUnderARandomOperationSequence() + { + Random random = new Random(20260823L); + Set> model = new HashSet<>(); + List history = new ArrayList<>(); + + for (int step = 0; step < 400; step++) + { + Class target = ALL.get(random.nextInt(ALL.size())); + boolean mark = random.nextBoolean(); + if (mark) + { + DataManager.markDirty(target); + model.add(target); + } + else + { + DataManager.markClean(target); + model.remove(target); + } + history.add((mark ? "markDirty(" : "markClean(") + target.getSimpleName() + ")"); + + // The tracker is a set; refinement against the reference implementation must hold at + // every step, not merely at the end of the sequence. + assertThat(DataManager.hasUnsavedChanges()) + .as("after step %d %s; history=%s", step, history.get(step), history) + .isEqualTo(!model.isEmpty()); + assertThat(currentSet()) + .as("membership after step %d %s", step, history.get(step)) + .containsExactlyInAnyOrderElementsOf(model); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/ArrayModifierTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/ArrayModifierTest.java new file mode 100644 index 0000000..67f7e6f --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/ArrayModifierTest.java @@ -0,0 +1,150 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * These helpers reshape sheet data. The defining property of every one of them is that they are + * total over their input: whatever one row looks like has no bearing on what happens to the next. + * A ragged row is the normal case for sheet data, not an error, and it must not be able to + * silence the rows behind it. + */ +class ArrayModifierTest +{ + /** + * The column of a table is one entry per row, in row order. A short row simply has nothing + * to contribute at that column - it is not a signal to stop reading. + *

+ * This is the exact shape of the {@code break}-where-{@code continue}-was-meant bug: the + * first row too short to reach the requested column ended the loop, so every row below it + * was silently blanked and whatever the caller was populating from that column lost its tail. + */ + @Test + @DisplayName("a short row leaves only its own cell blank and does not suppress the rows below it") + void shortRowDoesNotTruncateTheColumn() + { + Object[][] table = { + {"a0", "a1", "a2"}, + {"b0"}, // too short to reach column 1 + {"c0", "c1", "c2"}, + {}, // completely empty + {"e0", "e1"}, + }; + + Object[] column = ArrayModifier.getColumn(table, 1); + + assertThat(column).containsExactly("a1", "", "c1", "", "e1"); + } + + /** + * The result has one entry per row no matter what, so callers can zip it against the row + * index they already hold. + */ + @Test + @DisplayName("a column has exactly one entry per row, ragged or not") + void columnHasOneEntryPerRow() + { + Object[][] table = { + {"a"}, + {}, + {"c", "d", "e"}, + }; + + for (int col = 0; col < 4; col++) + assertThat(ArrayModifier.getColumn(table, col)).as("column %d", col).hasSize(table.length); + } + + /** + * Every value that exists at the requested column has to come back, whichever row it is in. + * Stated as a quantifier over the whole table rather than one hand-picked column, so no + * single early exit can hide inside it. + */ + @Test + @DisplayName("every cell present at column c is returned at its own row index") + void columnReturnsEveryPresentCell() + { + Object[][] table = { + {"r0c0", "r0c1", "r0c2", "r0c3"}, + {"r1c0"}, + {"r2c0", "r2c1"}, + {"r3c0", "r3c1", "r3c2"}, + {"r4c0", "r4c1", "r4c2", "r4c3"}, + }; + + for (int col = 0; col < 4; col++) + { + Object[] extracted = ArrayModifier.getColumn(table, col); + for (int row = 0; row < table.length; row++) + { + Object expected = col < table[row].length ? table[row][col] : ""; + assertThat(extracted[row]).as("row %d, column %d", row, col).isEqualTo(expected); + } + } + } + + @Test + @DisplayName("extracting a column does not disturb the table it was read from") + void getColumnDoesNotMutateItsInput() + { + Object[][] table = {{"a", "b"}, {"c"}, {"d", "e"}}; + + ArrayModifier.getColumn(table, 1); + + assertThat(table[0]).containsExactly("a", "b"); + assertThat(table[1]).containsExactly("c"); + assertThat(table[2]).containsExactly("d", "e"); + } + + /** + * trim drops a header block: the first {@code rows} rows and the first {@code cols} cells of + * every surviving row. Checked against the original coordinates so an off-by-one in either + * axis is visible. + */ + @Test + @DisplayName("trim drops exactly the leading rows and columns asked for and keeps the rest in order") + void trimRemovesOnlyTheRequestedHeaderBlock() + { + Object[][] table = new Object[4][5]; + for (int row = 0; row < 4; row++) + for (int col = 0; col < 5; col++) + table[row][col] = row + ":" + col; + + Object[][] trimmed = ArrayModifier.trim(table, 1, 2); + + assertThat(trimmed).hasNumberOfRows(3); + for (int row = 0; row < trimmed.length; row++) + { + assertThat(trimmed[row]).as("trimmed row %d", row).hasSize(3); + for (int col = 0; col < trimmed[row].length; col++) + assertThat(trimmed[row][col]).isEqualTo((row + 1) + ":" + (col + 2)); + } + } + + /** + * accommodateLength exists so a caller can index the result by position without a bounds + * check. Two things follow: the length is exactly what was asked for, and no slot is null. + * Existing entries must survive - a filler that overwrote real names would rename columns. + */ + @Test + @DisplayName("accommodateLength reaches the requested length without overwriting the entries already there") + void accommodateLengthPadsWithoutOverwriting() + { + String[] padded = ArrayModifier.accommodateLength(new String[] {"first", "second"}, 5); + + assertThat(padded).hasSize(5); + assertThat(padded[0]).isEqualTo("first"); + assertThat(padded[1]).isEqualTo("second"); + assertThat(padded).doesNotContainNull(); + } + + @Test + @DisplayName("accommodateLength to a shorter length keeps the leading entries") + void accommodateLengthTruncatesFromTheEnd() + { + String[] shortened = ArrayModifier.accommodateLength(new String[] {"a", "b", "c", "d"}, 2); + + assertThat(shortened).containsExactly("a", "b"); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/ArrayProcessorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/ArrayProcessorTest.java new file mode 100644 index 0000000..197bd48 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/ArrayProcessorTest.java @@ -0,0 +1,285 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Property-based tests for {@link ArrayProcessor}, a comma-separated row accumulator. + * + *

THEORY. The class implements two textbook transformations: + *

    + *
  • a free-monoid accumulator over rows: {@code newLine()} appends one row, so the + * row count is exactly the number of {@code newLine()} calls and rows never interact;
  • + *
  • a separator split, which is the inverse of a separator join. RFC 4180 (and plain + * {@code String.split}/{@code String.join} algebra) fixes the law: a record containing + * m unquoted separators has exactly m+1 fields, and joining those fields with + * the separator reproduces the record. Any field the split drops is data loss.
  • + *
+ * The fixed-width constructor {@code ArrayProcessor(int numColumns)} additionally promises + * rectangularity: every emitted row has exactly {@code numColumns} cells. + */ +public class ArrayProcessorTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private static Object[] row(ArrayProcessor processor, int index) + { + return processor.getTable()[index]; + } + + /** + * Field count demanded by the separator-split law, allowing for the single trailing separator + * that {@code newLine()} documents itself as stripping (the emitter appends "value," per cell, + * so one trailing comma is punctuation rather than an empty final field). + */ + private static int expectedFieldCount(String line) + { + String stripped = line.endsWith(",") ? line.substring(0, line.length() - 1) : line; + int commas = 0; + for (int i = 0; i < stripped.length(); i++) + if (stripped.charAt(i) == ',') + commas++; + return commas + 1; + } + + @Test + @DisplayName("a fresh processor holds no rows and getTable() is non-null") + void freshProcessorIsEmpty() + { + // The empty accumulator must encode as the empty table, never as null: null would force + // every caller to special-case the identity element. + assertThat(new ArrayProcessor().getTable()).isNotNull().isEmpty(); + assertThat(new ArrayProcessor(5).getTable()).isNotNull().isEmpty(); + assertThat(new ArrayProcessor("abc").getTable()).isNotNull().isEmpty(); + } + + @Test + @DisplayName("row count equals the number of newLine() calls (free monoid over rows)") + void rowCountIsAdditive() + { + ArrayProcessor processor = new ArrayProcessor(); + for (int n = 1; n <= 25; n++) + { + processor.append("a,b,c"); + processor.newLine(); + // Appending one row increments the length by exactly one; no row is merged or dropped. + assertThat(processor.getTable()).as("after %d newLine() calls", n).hasNumberOfRows(n); + } + } + + @Test + @DisplayName("split and join are mutual inverses for separator-free fields") + void splitAndJoinAreMutualInverses() + { + String[][] records = { + {"a", "b", "c"}, + {"only"}, + {"1", "2", "3", "4", "5"}, + {"a", "", "c"}, // an empty interior field is a field, not an absence + {"", "b"}, + }; + + for (String[] fields : records) + { + String line = String.join(",", fields); + ArrayProcessor processor = new ArrayProcessor(); + processor.append(line); + processor.newLine(); + + // join(split(x)) == x is the defining inverse relationship of a separator codec. + assertThat(row(processor, 0)).as("record %s", line).containsExactly((Object[]) fields); + } + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("field count obeys the separator law even when the final field is empty") + void trailingEmptyFieldsSurviveTheSplit() + { + // A record with m separators has m+1 fields (RFC 4180 s.2). Dropping empty fields at the + // end silently shortens the row, so a consumer reading by column index reads the wrong + // column or an out-of-range one. The single documented trailing-separator strip is already + // allowed for by expectedFieldCount(). + String[] lines = {"a,b,c", "a,,b", "a,,", ",,", "a,"}; + for (String line : lines) + { + ArrayProcessor processor = new ArrayProcessor(); + processor.append(line); + processor.newLine(); + assertThat(row(processor, 0)).as("record \"%s\"", line).hasSize(expectedFieldCount(line)); + } + } + + @Test + @DisplayName("the fixed-width constructor emits rectangular rows, padding short records") + void fixedWidthRowsAreRectangular() + { + String[] lines = {"a,b,c,d", "a,b,c", "a,b", "a", "a,b,c,", "a,,", ""}; + for (String line : lines) + { + ArrayProcessor processor = new ArrayProcessor(4); + processor.append(line); + processor.newLine(); + // Rectangularity is the whole contract of a declared column count: a table consumer + // indexes cell [r][c] for every r, so every row must have exactly numColumns cells. + assertThat(row(processor, 0)).as("record \"%s\"", line).hasSize(4); + } + } + + @Test + @DisplayName("fixed-width padding preserves the leading fields and pads with empty cells") + void fixedWidthPaddingIsOnTheRight() + { + ArrayProcessor processor = new ArrayProcessor(5); + processor.append("x,y"); + processor.newLine(); + // Padding may only add cells; the fields actually present keep their column indices, + // otherwise the column-to-meaning mapping shifts. + assertThat(row(processor, 0)).containsExactly("x", "y", "", "", ""); + } + + @Test + @DisplayName("a record wider than the declared width is rejected, never silently truncated") + void overflowIsRejectedAndLeavesTheTableUnchanged() + { + ArrayProcessor processor = new ArrayProcessor(2); + processor.append("a,b"); + processor.newLine(); + + processor.append("a,b,c,d"); + // With no documented truncation rule, the mathematically sane response to a record that + // does not fit the declared shape is rejection: silently dropping columns c and d would + // corrupt the table with no signal to the caller. + assertThatThrownBy(processor::newLine).isInstanceOf(IndexOutOfBoundsException.class); + + // Failure atomicity: a rejected operation must not leave a partial row behind. + assertThat(processor.getTable()).hasNumberOfRows(1); + assertThat(row(processor, 0)).containsExactly("a", "b"); + } + + @Test + @DisplayName("append is string concatenation: append(a);append(b) == append(a+b)") + void appendIsConcatenation() + { + String[][] splits = {{"a,b", ",c"}, {"", "a"}, {"a", ""}, {"a,", "b,c"}}; + for (String[] parts : splits) + { + ArrayProcessor split = new ArrayProcessor(); + split.append(parts[0]); + split.append(parts[1]); + split.newLine(); + + ArrayProcessor whole = new ArrayProcessor(); + whole.append(parts[0] + parts[1]); + whole.newLine(); + + // Concatenation is associative, so the buffer must not care where the caller cut it. + assertThat(row(split, 0)).as("%s + %s", parts[0], parts[1]).isEqualTo(row(whole, 0)); + } + } + + @Test + @DisplayName("substring composes: substring(i) then substring(j) == substring(i+j)") + void substringComposes() + { + ArrayProcessor composed = new ArrayProcessor(); + composed.append("abcdefgh"); + composed.substring(2); + composed.substring(3); + composed.newLine(); + + ArrayProcessor direct = new ArrayProcessor(); + direct.append("abcdefgh"); + direct.substring(5); + direct.newLine(); + + // Suffix-taking is a monoid action of the additive naturals on strings. + assertThat(row(composed, 0)).isEqualTo(row(direct, 0)); + assertThat(row(direct, 0)).containsExactly("fgh"); + + ArrayProcessor ranged = new ArrayProcessor(); + ranged.append("abcdefgh"); + ranged.substring(2, 5); + ranged.newLine(); + // Two-argument substring must agree with String.substring's half-open interval [2,5). + assertThat(row(ranged, 0)).containsExactly("abcdefgh".substring(2, 5)); + } + + @Test + @DisplayName("rows are independent: a later row cannot alter an earlier one") + void rowsAreIndependent() + { + ArrayProcessor processor = new ArrayProcessor(3); + processor.append("a,b,c"); + processor.newLine(); + Object[] firstBefore = row(processor, 0).clone(); + + processor.append("x,y,z"); + processor.newLine(); + + // Locality: appending row n+1 is a pure extension of the table, so row n is untouched. + assertThat(row(processor, 0)).isEqualTo(firstBefore); + assertThat(row(processor, 1)).containsExactly("x", "y", "z"); + } + + @Test + @DisplayName("getTable() is a pure query: repeated calls agree and do not consume the table") + void getTableIsAPureQuery() + { + ArrayProcessor processor = new ArrayProcessor(2); + processor.append("a,b"); + processor.newLine(); + + Object[][] first = processor.getTable(); + Object[][] second = processor.getTable(); + // Command/query separation: reading the table cannot be destructive. + assertThat(second).hasNumberOfRows(first.length); + assertThat(second[0]).isEqualTo(first[0]); + assertThat(processor.getTable()).hasNumberOfRows(1); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("an empty record produces a row rather than crashing") + void emptyRecordProducesARow() + { + // The empty string is a legal record: under the separator law it has exactly one (empty) + // field, and under the fixed-width contract it is a fully padded row. Either way the + // accumulator must be total on its own state - a NullPointerException from an internal + // uninitialised buffer is not a diagnosable answer. + ArrayProcessor fixed = new ArrayProcessor(3); + assertThatCode(fixed::newLine).doesNotThrowAnyException(); + assertThat(fixed.getTable()).hasNumberOfRows(1); + assertThat(row(fixed, 0)).containsExactly("", "", ""); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("append(null) is rejected at the call site") + void nullAppendIsRejectedEagerly() + { + ArrayProcessor processor = new ArrayProcessor(2); + // A null record is not a record. Rejecting it where it enters keeps the diagnosis at the + // faulty call site; accepting it defers the failure to an unrelated later newLine() (or, + // worse, stringifies it into the table as the text "null"). + assertThatThrownBy(() -> processor.append(null)).isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/BitStreamAlgebraTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/BitStreamAlgebraTest.java new file mode 100644 index 0000000..07312c9 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/BitStreamAlgebraTest.java @@ -0,0 +1,279 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Property-based tests for {@link BitStream}. + * + *

THEORY. A bit stream is a free monoid over the alphabet {0,1}: appending is an associative + * operation with the empty stream as its identity, and the only thing a serialiser is allowed to + * do is to place the k-th written bit at a well-defined, recoverable position in the output. + * Every assertion below is derived from that model plus the packing convention the class itself + * defines, never from observed output. + * + *

CONVENTION (derived from the source, not from running it). {@code append(boolean)} performs + * {@code bytes[nextBit / 8] |= 1 << (nextBit % 8)}: the k-th bit written lands in byte {@code k/8} + * at bit significance {@code k%8}. {@code append(byte)} iterates {@code i = 0..7} testing + * {@code b & (1 << i)}, i.e. it feeds the source byte least-significant-bit first. The stream is + * therefore LSB-first (little-endian bit order) within each byte, which is the packing used by the + * Nintendo DS LZ/Huffman-style bit readers this class exists to feed. + */ +public class BitStreamAlgebraTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + private static final long SEED = 20260823L; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + /** Decoder written from the stated LSB-first convention; it is the inverse of append(boolean). */ + private static boolean bitAt(byte[] bytes, int index) + { + return (bytes[index / Byte.SIZE] & (1 << (index % Byte.SIZE))) != 0; + } + + private static BitStream streamOf(boolean[] bits) + { + BitStream stream = new BitStream(); + for (boolean bit : bits) + stream.append(bit); + return stream; + } + + private static boolean[] randomBits(Random random, int length) + { + boolean[] bits = new boolean[length]; + for (int i = 0; i < length; i++) + bits[i] = random.nextBoolean(); + return bits; + } + + @Test + @DisplayName("the empty stream is the identity of the monoid: no bytes, empty rendering, no exception") + void emptyStream() + { + // A monoid's identity element carries no information, so its encoding must be the empty + // word; ceil(0/8) == 0 bytes. + assertThatCode(BitStream::new).doesNotThrowAnyException(); + assertThat(new BitStream().toBytes()).isEmpty(); + assertThat(new BitStream().toString()).isEmpty(); + } + + @Test + @DisplayName("a byte-aligned append(byte) is the identity on bytes (LSB-first conformance)") + void byteAlignedAppendIsIdentityOnBytes() + { + for (int value = 0; value < 256; value++) + { + byte b = (byte) value; + BitStream stream = new BitStream(); + stream.append(b); + // Bit i of the source is written to stream position i, and stream position i of the + // first byte has significance 1< significance 1<<1 => 0x02. + assertThat(single.toBytes()).containsExactly((byte) 0x02); + } + + @Test + @DisplayName("append(byte) is exactly eight append(boolean) calls, at every bit offset") + void appendByteIsAHomomorphism() + { + // Homomorphism law: the encoding of a byte must equal the concatenation of the encodings + // of its eight bits, whatever the current cursor offset. Offsets 0/3/7 straddle the byte + // boundary, which is where a byte-at-a-time fast path diverges from the bit-at-a-time one. + for (int offset : new int[] {0, 3, 7}) + { + for (int value = 0; value < 256; value++) + { + byte b = (byte) value; + + BitStream viaByte = new BitStream(); + viaByte.append(false, offset); + viaByte.append(b); + + BitStream viaBits = new BitStream(); + viaBits.append(false, offset); + for (int i = 0; i < Byte.SIZE; i++) + viaBits.append((b & (1 << i)) != 0); + + assertThat(viaByte.toBytes()) + .as("offset %d, value 0x%02X", offset, value) + .isEqualTo(viaBits.toBytes()); + } + } + } + + @Test + @DisplayName("round trip: every written bit is recoverable, at every length, including non-multiples of 8") + void roundTripAtManyLengths() + { + Random random = new Random(SEED); + int[] lengths = new int[46]; + for (int i = 0; i <= 40; i++) + lengths[i] = i; + lengths[41] = 1023; + lengths[42] = 1024; + lengths[43] = 1025; + lengths[44] = 8191; + lengths[45] = 8193; // beyond the default capacity: forces at least one growth + + for (int length : lengths) + { + boolean[] bits = randomBits(random, length); + byte[] encoded = streamOf(bits).toBytes(); + + // Encoding is injective on bit sequences: decoding with the inverse map must return + // the original word. This is the fundamental inverse-function property. + for (int i = 0; i < length; i++) + assertThat(bitAt(encoded, i)).as("length %d, bit %d", length, i).isEqualTo(bits[i]); + + // A stream of N bits occupies exactly ceil(N/8) bytes: no fewer (information loss), + // no more (a trailing all-padding byte would make the encoding non-canonical). + assertThat(encoded.length).as("byte count for %d bits", length).isEqualTo((length + 7) / Byte.SIZE); + + // Padding bits in the final partial byte carry no information, so they must be zero; + // otherwise two encoders of the same word could disagree byte-for-byte. + for (int i = length; i < encoded.length * Byte.SIZE; i++) + assertThat(bitAt(encoded, i)).as("padding bit %d of length %d", i, length).isFalse(); + } + } + + @Test + @DisplayName("append(value, count) is monoidal: count 0 is the identity and counts add") + void repeatedAppendIsMonoidal() + { + for (boolean value : new boolean[] {true, false}) + { + BitStream identity = new BitStream(); + identity.append(true); + identity.append(value, 0); + // Appending the empty word leaves the stream unchanged (identity element). + assertThat(identity.toBytes()).containsExactly((byte) 0x01); + + for (int m = 0; m <= 9; m++) + { + for (int n = 0; n <= 9; n++) + { + BitStream split = new BitStream(); + split.append(value, m); + split.append(value, n); + + BitStream whole = new BitStream(); + whole.append(value, m + n); + + // Concatenation of runs of the same symbol is addition on their lengths. + assertThat(split.toBytes()).as("%b: %d + %d", value, m, n).isEqualTo(whole.toBytes()); + } + } + } + } + + @Test + @DisplayName("toString() renders the bit sequence in descending stream order") + void toStringIsTheReversedBitSequence() + { + BitStream stream = new BitStream(); + stream.append(true); + stream.append(true); + stream.append(false, 14); + // toString() inserts each successive byte at the front, and renders each byte MSB-first; + // composing those two reversals means character j of the result is stream bit + // (8*byteCount - 1 - j). Bits 0 and 1 are set, so only the last two characters are '1'. + assertThat(stream.toString()).isEqualTo("0000000000000011"); + + Random random = new Random(SEED + 1); + for (int bytes = 1; bytes <= 6; bytes++) + { + boolean[] bits = randomBits(random, bytes * Byte.SIZE); + String rendered = streamOf(bits).toString(); + assertThat(rendered).as("length %d", bits.length).hasSize(bits.length); + for (int i = 0; i < bits.length; i++) + { + // Same law, checked against an independently built expectation. + char expected = bits[bits.length - 1 - i] ? '1' : '0'; + assertThat(rendered.charAt(i)).as("char %d of %d", i, bits.length).isEqualTo(expected); + } + } + } + + @Test + @DisplayName("toBytes() is a pure query: repeatable and defensively copied") + void toBytesIsAPureQuery() + { + BitStream stream = new BitStream(); + stream.append((byte) 0x5A); + + byte[] first = stream.toBytes(); + byte[] second = stream.toBytes(); + // A query must be idempotent: asking twice cannot change the answer. + assertThat(second).isEqualTo(first); + + first[0] = 0x00; + // The returned array is a snapshot; handing out the live buffer would let a reader corrupt + // the stream, breaking the round-trip property for every subsequent caller. + assertThat(stream.toBytes()).containsExactly((byte) 0x5A); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("a growable stream accepts appends regardless of its initial capacity") + void growsFromAnyInitialCapacity() + { + // A self-growing buffer is total in the number of appends: the initial capacity is a + // performance hint, never a limit on the language accepted. Capacity 0 is a legal hint + // ("I do not know how big this will be"), so 100 bits must still round-trip. + Random random = new Random(SEED + 2); + for (int capacity : new int[] {0, 1, 7, 8, 9, 16}) + { + boolean[] bits = randomBits(random, 100); + BitStream stream = new BitStream(capacity); + for (boolean bit : bits) + stream.append(bit); + + byte[] encoded = stream.toBytes(); + for (int i = 0; i < bits.length; i++) + assertThat(bitAt(encoded, i)).as("capacity %d, bit %d", capacity, i).isEqualTo(bits[i]); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/BitVectorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/BitVectorTest.java new file mode 100644 index 0000000..8ec4ba3 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/BitVectorTest.java @@ -0,0 +1,203 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +/** + * A bit vector is defined by one property above all others: each index names its own bit and + * nothing else. Every test here is an instance of that, because the ways this class has broken + * (a mask of {@code idx} instead of {@code 1L << idx}, an allocation sized by truncating + * division) are all failures of one index to keep out of another index's business. + */ +class BitVectorTest +{ + /** + * Independence. Setting bit i must leave every other bit clear - not "roughly the right + * bits". A mask of {@code idx} rather than {@code 1L << (idx % 64)} makes setBit(3) light + * up bits 0 and 1 as well, which no assertion about bit 3 alone would ever notice. + */ + @Test + @DisplayName("setting bit i leaves bit j set if and only if i == j") + void setBitAffectsOnlyThatBit() + { + int size = 200; + for (int i = 0; i < size; i++) + { + BitVector vector = new BitVector(size); + vector.setBit(i); + + for (int j = 0; j < size; j++) + { + assertThat(vector.isSet(j)) + .withFailMessage("after setBit(%d), isSet(%d) was %s", i, j, vector.isSet(j)) + .isEqualTo(i == j); + } + } + } + + /** + * A freshly built vector holds nothing. Without this the independence test above could be + * satisfied by a vector which reports every bit as set. + */ + @Test + @DisplayName("a new vector has no bits set") + void newVectorIsEmpty() + { + BitVector vector = new BitVector(200); + for (int i = 0; i < 200; i++) + assertThat(vector.isSet(i)).as("bit %d of a new vector", i).isFalse(); + } + + /** + * Capacity. {@code new BitVector(n)} promises n usable bits; sizing the backing array with + * {@code n / 64} instead of a rounding-up division makes every index in the final partial + * word blow up with an ArrayIndexOutOfBoundsException. + */ + @ParameterizedTest(name = "a vector of {0} bits addresses all of 0..{0}-1") + @ValueSource(ints = {1, 2, 63, 64, 65, 100, 127, 128, 129, 255, 256, 1000}) + @DisplayName("a vector of n bits can address every index from 0 to n-1") + void everyDeclaredIndexIsAddressable(int size) + { + BitVector vector = new BitVector(size); + + for (int i = 0; i < size; i++) + { + final int idx = i; + assertThat(vector.isSet(idx)).as("isSet(%d) of %d bits", idx, size).isFalse(); + vector.setBit(idx); + assertThat(vector.isSet(idx)).as("setBit(%d) of %d bits", idx, size).isTrue(); + } + + // and all of them at once, so the last word is not merely reachable but distinct + for (int i = 0; i < size; i++) + assertThat(vector.isSet(i)).as("bit %d after setting all %d", i, size).isTrue(); + } + + /** + * set and clear are inverses, and clearing is as narrow as setting. Starting from a fully + * populated vector means a clear which reached into a neighbouring bit shows up immediately. + */ + @Test + @DisplayName("clearing bit i undoes setting bit i and touches nothing else") + void clearBitIsTheInverseOfSetBit() + { + int size = 130; + for (int i = 0; i < size; i++) + { + BitVector vector = new BitVector(size); + for (int j = 0; j < size; j++) + vector.setBit(j); + + vector.clearBit(i); + + for (int j = 0; j < size; j++) + { + assertThat(vector.isSet(j)) + .withFailMessage("after clearBit(%d) on a full vector, isSet(%d) was %s", i, j, vector.isSet(j)) + .isEqualTo(i != j); + } + + vector.setBit(i); + assertThat(vector.isSet(i)).as("setBit after clearBit restores bit %d", i).isTrue(); + } + } + + /** + * Idempotence - setting a bit twice is the same as setting it once, and likewise for clear. + * A mask built by addition rather than a bitwise or would break here. + */ + @Test + @DisplayName("setting or clearing the same bit twice is the same as doing it once") + void repeatedOperationsAreIdempotent() + { + BitVector twice = new BitVector(128); + BitVector once = new BitVector(128); + + twice.setBit(70); + twice.setBit(70); + once.setBit(70); + + assertThat(twice.toLongs()).isEqualTo(once.toLongs()); + + twice.clearBit(70); + twice.clearBit(70); + once.clearBit(70); + + assertThat(twice.toLongs()).isEqualTo(once.toLongs()); + } + + /** + * toLongs must hand back a snapshot, not the live array - a caller who is handed the + * internals can scribble on the vector by accident. + */ + @Test + @DisplayName("toLongs returns a copy that later writes do not alter") + void toLongsIsDefensivelyCopied() + { + BitVector vector = new BitVector(64); + long[] before = vector.toLongs(); + + vector.setBit(5); + + assertThat(before).as("snapshot taken before setBit(5)").containsOnly(0L); + assertThat(vector.toLongs()).as("live state after setBit(5)").isNotEqualTo(before); + } + + /** + * Out-of-range indices must be reported, not silently redirected onto some other bit. + * {@code 1L << (-1 % 64)} is {@code 1L << -1}, which Java evaluates as {@code 1L << 63} - + * so setBit(-1) quietly sets the top bit of word 0, corrupting a perfectly valid bit. + * The same argument applies at the top end: a vector of 100 bits owns indices 0..99, and + * index 100 happens to land in the unused slack of the second word. + *

+ * Both ends are checked softly so a failure reports every case, not just the first. + */ + @Test + @DisplayName("an index outside 0..n-1 is rejected instead of silently writing some other bit") + void outOfRangeIndicesAreRejected() + { + assertSoftly(softly -> { + BitVector low = new BitVector(64); + try { + low.setBit(-1); + } + catch (RuntimeException expected) { + // rejecting outright is a perfectly good answer + } + softly.assertThat(low.toLongs()) + .as("a vector of 64 bits after an attempted setBit(-1) - no valid bit may have been written") + .containsOnly(0L); + + BitVector high = new BitVector(100); + try { + high.setBit(100); + } + catch (RuntimeException expected) { + // likewise + } + softly.assertThat(high.toLongs()) + .as("a vector of 100 bits after an attempted setBit(100) - index 100 is not part of the vector") + .containsOnly(0L); + }); + } + + /** + * An index past the end of the backing store must fail loudly rather than being folded back + * onto a live bit by the modulo in the mask. + */ + @Test + @DisplayName("an index far past the end of the vector throws rather than wrapping around") + void wildIndexThrows() + { + BitVector vector = new BitVector(64); + + assertThatThrownBy(() -> vector.setBit(1_000)) + .isInstanceOf(IndexOutOfBoundsException.class); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/CsvReaderTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/CsvReaderTest.java new file mode 100644 index 0000000..ad64957 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/CsvReaderTest.java @@ -0,0 +1,223 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * CSV is a defined format (RFC 4180), so these are conformance tests, not descriptions of the + * parser. The failures they guard all have the same consequence for a spreadsheet: a field is + * split, merged or dropped, every column after it shifts by one, and the wrong numbers get + * written into the ROM without anything looking obviously wrong on screen. + */ +class CsvReaderTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + @TempDir + Path temp; + + private String[][] parse(String content) throws IOException + { + return parse(content, 0, 0); + } + + private String[][] parse(String content, int firstX, int firstY) throws IOException + { + Path file = temp.resolve("sheet-" + System.nanoTime() + ".csv"); + Files.write(file, content.getBytes(StandardCharsets.UTF_8)); + return new CsvReader(file.toString(), firstX, firstY).getCsv(); + } + + /** + * A comma inside a quoted field belongs to the field. Move descriptions and item names + * routinely contain one, and splitting there silently shifts every later column. + */ + @Test + @DisplayName("a comma inside a quoted field is part of the field, not a separator") + void quotedCommaDoesNotSplitTheField() throws IOException + { + assertThat(parse("Growl,\"Lowers the target's Attack, sharply.\",40\n")[0]) + .containsExactly("Growl", "Lowers the target's Attack, sharply.", "40"); + } + + /** + * Inside a quoted field, two double quotes stand for one literal double quote, and the + * quotes that delimit the field are not part of it. + */ + @Test + @DisplayName("a doubled quote inside a quoted field reads back as one literal quote") + void doubledQuoteReadsAsOneQuote() throws IOException + { + assertThat(parse("a,\"say \"\"hi\"\" now\",b\n")[0]) + .containsExactly("a", "say \"hi\" now", "b"); + } + + /** + * Trailing empty fields are fields. {@code "a,b,,".split(",")} yields two elements, so a row + * whose last columns are blank comes back short - and because sheet columns are read + * positionally, every consumer downstream reads the wrong column or falls off the end. + */ + @Test + @DisplayName("trailing empty fields are preserved rather than dropped") + void trailingEmptyFieldsSurvive() throws IOException + { + assertThat(parse("a,b,,\n")[0]).containsExactly("a", "b", "", ""); + assertThat(parse(",,,\n")[0]).containsExactly("", "", "", ""); + assertThat(parse("a,\n")[0]).containsExactly("a", ""); + } + + @Test + @DisplayName("empty fields in the middle of a row are preserved in place") + void interiorEmptyFieldsKeepTheirPosition() throws IOException + { + assertThat(parse("a,,c,,e\n")[0]).containsExactly("a", "", "c", "", "e"); + } + + /** + * The exported files hold Pokemon names, and several of them are not ASCII. Reading (or + * writing) with the platform charset turns Nidoran-female into "Nidoran?", which then gets + * saved back over the real name. + */ + @Test + @DisplayName("non-ASCII text survives being read back") + void nonAsciiSurvives() throws IOException + { + assertThat(parse("Nidoran♀,Nidoran♂,Flabébé,ピカチュウ\n")[0]) + .containsExactly("Nidoran♀", "Nidoran♂", "Flabébé", "ピカチュウ"); + } + + /** + * A byte order mark is a byte order mark, not the first character of the first cell. A sheet + * exported by Excel starts with one, and leaving it attached makes the first header cell + * fail to match anything. + */ + @Test + @DisplayName("a UTF-8 byte order mark is not treated as part of the first field") + void byteOrderMarkIsStripped() throws IOException + { + assertThat(parse("id,name\n")[0]).containsExactly("id", "name"); + } + + /** + * A field which contains a line break is one field of one record - RFC 4180 allows it inside + * quotes precisely so that multi-line text can be carried. Reading record-by-record as + * physical lines tears such a field in half and turns one row into two. + */ + @Tag(DEAD_CODE) + @Test + @DisplayName("a line break inside a quoted field stays inside that field") + void quotedLineBreakDoesNotStartANewRecord() throws IOException + { + String[][] parsed = parse("a,\"line one\nline two\",b\n"); + + assertThat(parsed).as("a file holding a single record").hasNumberOfRows(1); + assertThat(parsed[0]).containsExactly("a", "line one\nline two", "b"); + } + + @Test + @DisplayName("records are returned in file order, one per row") + void recordsKeepFileOrder() throws IOException + { + String[][] parsed = parse("r0a,r0b\nr1a,r1b\nr2a,r2b\n"); + + assertThat(parsed).hasNumberOfRows(3); + assertThat(parsed[0]).containsExactly("r0a", "r0b"); + assertThat(parsed[1]).containsExactly("r1a", "r1b"); + assertThat(parsed[2]).containsExactly("r2a", "r2b"); + } + + /** + * next() walks the records once and then reports exhaustion, so a caller looping until null + * terminates rather than reading past the end. + */ + @Test + @DisplayName("next walks every record once and then reports exhaustion") + void nextWalksEveryRecordThenReturnsNull() throws IOException + { + Path file = temp.resolve("walk.csv"); + Files.write(file, "a,b\nc,d\n".getBytes(StandardCharsets.UTF_8)); + CsvReader reader = new CsvReader(file.toString(), 0, 0); + + assertThat(reader.length()).isEqualTo(2); + assertThat(reader.next()).containsExactly("a", "b"); + assertThat(reader.next()).containsExactly("c", "d"); + assertThat(reader.next()).isNull(); + } + + /** + * The firstX/firstY arguments drop a header block. What is left has to be the rest of the + * grid, unshifted - an off-by-one in either axis silently re-labels every column. + */ + @Test + @DisplayName("firstX and firstY drop exactly the header rows and columns requested") + void headerRowsAndColumnsAreDropped() throws IOException + { + String[][] parsed = parse("h0,h1,h2,h3\nx,y,a,b\nx,y,c,d\n", 2, 1); + + assertThat(parsed).hasNumberOfRows(2); + assertThat(parsed[0]).containsExactly("a", "b"); + assertThat(parsed[1]).containsExactly("c", "d"); + } + + /** + * A table is allowed to be wider than it is tall - a one row sheet with twenty columns is a + * perfectly ordinary export. Sizing a per-column array by the number of rows and then + * indexing it by column number throws the moment that stops being true. + */ + @Test + @DisplayName("a table with more columns than rows can be printed without throwing") + void moreColumnsThanRowsDoesNotThrow() throws IOException + { + Path file = temp.resolve("wide.csv"); + Files.write(file, "a,b,c,d,e,f,g,h\n".getBytes(StandardCharsets.UTF_8)); + CsvReader reader = new CsvReader(file.toString(), 0, 0); + + assertThatCode(() -> printQuietly(reader)).doesNotThrowAnyException(); + } + + /** + * Ragged rows are the normal case once trailing empties are preserved, so the same + * per-column bookkeeping has to cope with rows of different widths. + */ + @Test + @DisplayName("a ragged table can be printed without throwing") + void raggedTableDoesNotThrow() throws IOException + { + Path file = temp.resolve("ragged.csv"); + Files.write(file, "a\na,b,c,d,e\na,b\n".getBytes(StandardCharsets.UTF_8)); + CsvReader reader = new CsvReader(file.toString(), 0, 0); + + assertThatCode(() -> printQuietly(reader)).doesNotThrowAnyException(); + } + + /** print() writes to stdout; the test only cares whether it survives the table's shape. */ + private static void printQuietly(CsvReader reader) + { + PrintStream original = System.out; + System.setOut(new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)); + try { + reader.print(); + } + finally { + System.setOut(original); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/CsvRoundTripTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/CsvRoundTripTest.java new file mode 100644 index 0000000..5fed89b --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/CsvRoundTripTest.java @@ -0,0 +1,182 @@ +package io.github.turtleisaac.pokeditor.framework; + +import io.github.turtleisaac.pokeditor.gui.PokeditorManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Export and import are one feature: a sheet the tool wrote must come back as the same sheet. + * Testing the two halves separately cannot catch a quoting convention that the writer emits and + * the reader does not accept, so these tests state the property that actually matters to a user - + * export, edit nothing, re-import, get the same table. + *

+ * The export itself ({@code PokeditorManager.writeSheet}) is reachable only behind a modal + * JFileChooser, so it cannot be driven headlessly. {@link #write} therefore reproduces the loop + * from {@code writeSheet} - join the quoted fields with commas, one record per line, UTF-8 - and + * delegates the part that carries the actual format decisions, field quoting, to the production + * method itself. + */ +class CsvRoundTripTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + @TempDir + Path temp; + + private static Method quoteCsvField; + + @BeforeAll + static void locateWriterQuoting() throws NoSuchMethodException + { + quoteCsvField = PokeditorManager.class.getDeclaredMethod("quoteCsvField", String.class); + quoteCsvField.setAccessible(true); + } + + private static String quote(String field) + { + try { + return (String) quoteCsvField.invoke(null, field); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new AssertionError("could not invoke the export path's field quoting", e); + } + } + + /** The serialisation loop from {@code PokeditorManager.writeSheet}, minus the file chooser. */ + private Path write(String[][] table) throws IOException + { + StringBuilder out = new StringBuilder(); + for (String[] row : table) + { + String[] quoted = new String[row.length]; + for (int i = 0; i < row.length; i++) + quoted[i] = quote(row[i]); + out.append(String.join(",", quoted)).append("\n"); + } + + Path file = temp.resolve("export-" + System.nanoTime() + ".csv"); + Files.write(file, out.toString().getBytes(StandardCharsets.UTF_8)); + return file; + } + + private String[][] writeThenRead(String[][] table) throws IOException + { + return new CsvReader(write(table).toString(), 0, 0).getCsv(); + } + + /** + * A field which needs no quoting must not acquire any, otherwise every ordinary cell in the + * file gains a pair of quotes that a stricter reader would hand back as part of the value. + */ + @Test + @DisplayName("a field with nothing special in it is written unquoted") + void ordinaryFieldIsNotQuoted() + { + assertThat(quote("Pikachu")).isEqualTo("Pikachu"); + assertThat(quote("")).isEqualTo(""); + assertThat(quote("Nidoran♀")).isEqualTo("Nidoran♀"); + } + + /** + * A field carrying a comma, a quote or a line break has to be quoted, because those are the + * three characters that would otherwise be read as structure rather than content. An + * embedded quote is doubled inside the quoted field. + */ + @Test + @DisplayName("a field containing a comma, a quote or a line break is quoted, and inner quotes are doubled") + void hazardousFieldsAreQuoted() + { + assertThat(quote("a,b")).isEqualTo("\"a,b\""); + assertThat(quote("say \"hi\"")).isEqualTo("\"say \"\"hi\"\"\""); + assertThat(quote("line one\nline two")).isEqualTo("\"line one\nline two\""); + assertThat(quote("carriage\rreturn")).isEqualTo("\"carriage\rreturn\""); + } + + /** A missing cell has to become an empty field, not the four characters "null". */ + @Test + @DisplayName("a null field is written as an empty field") + void nullFieldBecomesEmpty() + { + assertThat(quote(null)).isEqualTo(""); + } + + /** + * The round trip, over a table that carries every hazard at once except an embedded line + * break (which has its own test below): commas, quotes, quotes next to commas, empty cells, + * trailing empty cells and non-ASCII names. + */ + @Test + @DisplayName("a table of commas, quotes, blanks and non-ASCII names survives export and re-import unchanged") + void roundTripPreservesEveryFieldButLineBreaks() throws IOException + { + String[][] table = { + {"id", "name", "description", "flags", "note"}, + {"29", "Nidoran♀", "Lowers Attack, sharply.", "", ""}, + {"30", "Nidorina", "He said \"no\" twice", "a,b", "trailing"}, + {"31", "Nidoqueen", "\"fully quoted\"", ",leading comma", ""}, + {"32", "Flabébé", "", "", ""}, + }; + + assertThat(writeThenRead(table)).isDeepEqualTo(table); + } + + /** + * A cell whose text runs onto a second line is still one cell of one row. If the round trip + * cannot carry it, re-importing an exported sheet gains a row and every entry below the + * offending one is written to the wrong index. + */ + @Tag(DEAD_CODE) + @Test + @DisplayName("a table containing an embedded line break survives export and re-import unchanged") + void roundTripPreservesEmbeddedLineBreaks() throws IOException + { + String[][] table = { + {"id", "description"}, + {"1", "first line\nsecond line"}, + {"2", "plain"}, + }; + + assertThat(writeThenRead(table)).isDeepEqualTo(table); + } + + /** + * Row shape is part of the table. A row whose last cells are blank must come back with those + * cells still present, or the importer reads a short row and every column index after the + * gap addresses the wrong field. + */ + @Test + @DisplayName("every row comes back with exactly the number of fields it was written with") + void roundTripPreservesRowWidths() throws IOException + { + String[][] table = { + {"a", "b", "c", "d"}, + {"", "", "", ""}, + {"x", "", "", ""}, + {"", "", "", "z"}, + }; + + String[][] reread = writeThenRead(table); + + for (int row = 0; row < table.length; row++) + assertThat(reread[row]).as("row %d", row).hasSameSizeAs(table[row]); + assertThat(reread).isDeepEqualTo(table); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/DirectoryTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/DirectoryTest.java new file mode 100644 index 0000000..5543bc9 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/DirectoryTest.java @@ -0,0 +1,126 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * {@link Directory#delete()} is a boolean-returning operation, so its contract is entirely about + * that boolean: it is an answer to "did I remove this directory?", and callers branch on it. + * A method which returns {@code true} unconditionally is not a weaker version of that contract, + * it is the absence of one - every caller then believes a failed cleanup succeeded. + */ +class DirectoryTest +{ + @TempDir + Path temp; + + /** + * Success is reported as success, and success means the tree is actually gone - including + * the nested content, which a non-recursive delete could never remove. + */ + @Test + @DisplayName("deleting a populated tree removes every file in it and reports true") + void deletingAPopulatedTreeSucceedsAndEmptiesIt() throws IOException + { + Path root = temp.resolve("root"); + Files.createDirectories(root.resolve("nested/deeper")); + Files.writeString(root.resolve("top.txt"), "top"); + Files.writeString(root.resolve("nested/middle.txt"), "middle"); + Files.writeString(root.resolve("nested/deeper/bottom.txt"), "bottom"); + + boolean deleted = new Directory(root.toString()).delete(); + + assertThat(deleted).as("return value of delete() on a tree it was able to remove").isTrue(); + assertThat(root).as("the tree after a delete which reported success").doesNotExist(); + } + + @Test + @DisplayName("deleting an empty directory removes it and reports true") + void deletingAnEmptyDirectorySucceeds() throws IOException + { + Path root = Files.createDirectory(temp.resolve("empty")); + + boolean deleted = new Directory(root.toString()).delete(); + + assertThat(deleted).isTrue(); + assertThat(root).doesNotExist(); + } + + /** + * Nothing was deleted, so the answer is no. A caller that logs "cleanup failed" on false has + * to be told the truth here, and it must find that out through a return value rather than an + * exception - the callers of this run during shutdown paths. + */ + @Test + @DisplayName("a directory that does not exist is reported as not deleted rather than throwing") + void absentDirectoryIsReportedNotThrown() + { + Directory absent = new Directory(temp.resolve("never-created").toString()); + + assertThatCode(absent::delete).doesNotThrowAnyException(); + assertThat(absent.delete()).as("return value of delete() on a path that was never there").isFalse(); + } + + /** + * A path which is not a directory at all cannot be cleared as one. Reporting true here would + * tell the caller a directory was emptied when the path on disk is untouched. + */ + @Test + @DisplayName("a path that is a plain file is reported as not deleted and is left on disk") + void plainFileIsReportedNotDeleted() throws IOException + { + Path file = Files.writeString(temp.resolve("not-a-directory.txt"), "contents"); + + boolean deleted = new Directory(file.toString()).delete(); + + assertThat(deleted).as("return value of delete() on a path which is not a directory").isFalse(); + assertThat(file).as("a file which delete() declined to remove").exists(); + } + + /** + * The return value has to be sensitive to a failure buried anywhere in the tree, not just at + * the top. Where the platform lets us make one child undeletable, a delete which cannot + * remove everything must not claim it did. + *

+ * Denying write permission on the parent is what stops a child being unlinked on POSIX; it + * has no effect for a superuser, so where the check cannot be set up the assertion about the + * top-level directory still stands. + */ + @Test + @DisplayName("a delete which could not remove everything does not report success") + void partialFailureIsNotReportedAsSuccess() throws IOException + { + Path root = Files.createDirectory(temp.resolve("locked-root")); + Path locked = Files.createDirectory(root.resolve("locked")); + Path trapped = Files.writeString(locked.resolve("trapped.txt"), "cannot go"); + + File lockedFile = locked.toFile(); + boolean readOnlyApplied = lockedFile.setWritable(false, false); + + boolean deleted = new Directory(root.toString()).delete(); + + // restore permissions first so the temp dir can be torn down whatever the outcome + lockedFile.setWritable(true, false); + + if (readOnlyApplied && Files.exists(trapped)) + { + assertThat(deleted).as("return value when a file inside the tree survived the delete").isFalse(); + assertThat(root).as("the tree the delete could not finish").exists(); + } + else + { + // the platform (or a superuser) let the delete through - then it really did succeed + assertThat(deleted).as("return value when the whole tree was in fact removed").isTrue(); + assertThat(root).doesNotExist(); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/F_Payload_A.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/F_Payload_A.java new file mode 100644 index 0000000..e7168b6 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/F_Payload_A.java @@ -0,0 +1,18 @@ +package io.github.turtleisaac.pokeditor.framework; + +/** + * A deliberately tiny compiled payload used by {@link JarClassLoaderTest}. + * + *

Its compiled bytes are copied into throwaway jars at test time. The class name is also + * rewritten in-place inside those bytes (an equal-length substitution, so the constant pool stays + * valid) to synthesise classes that exist ONLY inside a jar and not on the test classpath - which + * is what makes an isolation/delegation test meaningful. Keep the class name exactly 11 characters + * long and keep this class trivial. + */ +public class F_Payload_A +{ + public static String id() + { + return "payload"; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/JarClassLoaderTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/JarClassLoaderTest.java new file mode 100644 index 0000000..b15ac3d --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/JarClassLoaderTest.java @@ -0,0 +1,392 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Property-based tests for {@link JarClassLoader}, the loader used for external plugin jars. + * + *

THEORY. + *

    + *
  • Delegation policy. {@code JarClassLoader} extends {@link java.net.URLClassLoader} + * and does not override {@code loadClass}, so the policy in force is the one specified by + * {@link ClassLoader#loadClass(String, boolean)}: parent first. A name the parent + * can resolve is resolved by the parent, and the jar is consulted only for names the parent + * does not know. This is asserted explicitly below, because it is a security- and + * correctness-relevant property either way: a plugin can never shadow a host class, and + * equally can never be isolated from one.
  • + *
  • Diagnosability. Every failure mode of loading foreign code - absent jar, corrupt + * jar, jar without the requested class - must surface through the loader's declared checked + * exceptions, naming the class or the jar. A raw NPE identifies neither.
  • + *
  • Resource ownership. A loader that opens a jar owns an OS file handle; it must be + * closeable and closing it must release the handle, or long-running tools leak descriptors + * and (on Windows) lock plugin files against replacement.
  • + *
+ */ +public class JarClassLoaderTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + private static final String PACKAGE_PATH = "io/github/turtleisaac/pokeditor/framework/"; + private static final String PACKAGE_NAME = "io.github.turtleisaac.pokeditor.framework."; + private static final String TEMPLATE = "F_Payload_A"; + + @TempDir + Path tempDir; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + /** The compiled bytes of {@link F_Payload_A}, straight off the test classpath. */ + private static byte[] templateBytes() throws IOException + { + try (var in = JarClassLoaderTest.class.getResourceAsStream("/" + PACKAGE_PATH + TEMPLATE + ".class")) + { + assertThat(in).as("compiled payload must be on the test classpath").isNotNull(); + return in.readAllBytes(); + } + } + + /** + * Rewrites the payload's simple name inside its compiled bytes. The replacement has the same + * length as the original, so every constant-pool UTF8 length prefix stays correct. + */ + private static byte[] renamedPayload(String newSimpleName) throws IOException + { + assertThat(newSimpleName).hasSameSizeAs(TEMPLATE); + byte[] bytes = templateBytes(); + byte[] from = TEMPLATE.getBytes(StandardCharsets.US_ASCII); + byte[] to = newSimpleName.getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i + from.length <= bytes.length; i++) + { + boolean match = true; + for (int j = 0; j < from.length && match; j++) + match = bytes[i + j] == from[j]; + if (match) + System.arraycopy(to, 0, bytes, i, to.length); + } + return bytes; + } + + private Path buildJar(String name, Map entries, Manifest manifest) throws IOException + { + Path jar = tempDir.resolve(name); + try (OutputStream out = Files.newOutputStream(jar); + JarOutputStream jos = manifest == null ? new JarOutputStream(out) : new JarOutputStream(out, manifest)) + { + for (Map.Entry entry : entries.entrySet()) + { + jos.putNextEntry(new JarEntry(entry.getKey())); + jos.write(entry.getValue()); + jos.closeEntry(); + } + } + return jar; + } + + private Path payloadJar(String name, String... simpleNames) throws IOException + { + Map entries = new LinkedHashMap<>(); + for (String simpleName : simpleNames) + entries.put(PACKAGE_PATH + simpleName + ".class", + simpleName.equals(TEMPLATE) ? templateBytes() : renamedPayload(simpleName)); + return buildJar(name, entries, null); + } + + private static JarClassLoader loaderFor(Path jar) throws IOException + { + return new JarClassLoader(jar.toUri().toURL()); + } + + /** Open file descriptors in this JVM that point at the given file (Linux /proc). */ + private static List openDescriptorsFor(Path file) throws IOException + { + Path fdDir = Path.of("/proc/self/fd"); + Assumptions.assumeTrue(Files.isDirectory(fdDir), "descriptor check needs Linux /proc"); + List hits = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(fdDir)) + { + for (Path fd : stream) + { + try + { + Path target = Files.readSymbolicLink(fd); + if (target.toString().equals(file.toAbsolutePath().toString())) + hits.add(fd.getFileName() + " -> " + target); + } + catch (IOException ignored) + { + // descriptor vanished between listing and reading; not our jar + } + } + } + return hits; + } + + @Test + @DisplayName("a class that exists only inside the jar is loaded, and defined by this loader") + void classOnlyInTheJarIsLoadedFromTheJar() throws Exception + { + Path jar = payloadJar("only.jar", "F_Payload_Z"); + try (JarClassLoader loader = loaderFor(jar)) + { + // Precondition of the experiment: the name is genuinely unknown to the parent, so a + // successful load can only have come from the jar. + assertThatThrownBy(() -> Class.forName(PACKAGE_NAME + "F_Payload_Z")) + .isInstanceOf(ClassNotFoundException.class); + + Class loaded = loader.loadClass(PACKAGE_NAME + "F_Payload_Z"); + + // A class is identified by (name, defining loader). Loading foreign code must produce + // a class defined by the plugin loader - that is the entire purpose of the class. + assertThat(loaded.getName()).isEqualTo(PACKAGE_NAME + "F_Payload_Z"); + assertThat(loaded.getClassLoader()).isSameAs(loader); + } + } + + @Test + @DisplayName("delegation policy is parent-first: a name the parent knows is resolved by the parent") + void delegationIsParentFirst() throws Exception + { + // The jar contains its own copy of a class that is ALSO on the parent classpath. + Path jar = payloadJar("shadow.jar", TEMPLATE); + try (JarClassLoader loader = loaderFor(jar)) + { + Class loaded = loader.loadClass(PACKAGE_NAME + TEMPLATE); + + // ClassLoader.loadClass specifies parent delegation before self-search, and + // JarClassLoader does not override it. So the jar's copy is NOT used: the resulting + // Class is literally the one the application loader already defined. Plugins can + // therefore never shadow host classes - and are never isolated from them either. + assertThat(loaded).isSameAs(F_Payload_A.class); + assertThat(loaded.getClassLoader()).isSameAs(F_Payload_A.class.getClassLoader()); + assertThat(loaded.getClassLoader()).isNotSameAs(loader); + } + } + + @Test + @DisplayName("requesting an absent class fails with ClassNotFoundException naming the class") + void absentClassIsNamedInTheFailure() throws Exception + { + Path jar = payloadJar("present.jar", "F_Payload_Z"); + try (JarClassLoader loader = loaderFor(jar)) + { + // The one fact the caller cannot recover from the exception type alone. + assertThatThrownBy(() -> loader.loadClass("com.example.NotHere")) + .isInstanceOf(ClassNotFoundException.class) + .hasMessageContaining("com.example.NotHere"); + } + } + + @Test + @DisplayName("a jar containing no classes is a valid, empty plugin - not a crash") + void jarWithNoClassesIsHandled() throws Exception + { + Path jar = buildJar("no-classes.jar", Map.of("readme.txt", "nothing here".getBytes(StandardCharsets.UTF_8)), null); + try (JarClassLoader loader = loaderFor(jar)) + { + assertThatThrownBy(() -> loader.loadClass(PACKAGE_NAME + "F_Payload_Z")) + .isInstanceOf(ClassNotFoundException.class); + // No manifest at all means no Main-Class attribute, which the documented contract + // says is reported as null rather than as a failure. + assertThat(loader.getMainClassName()).isNull(); + } + } + + @Test + @DisplayName("a missing jar fails through the declared channels, naming the jar or class") + void missingJarFailsDiagnosably() throws Exception + { + Path missing = tempDir.resolve("not-there.jar"); + try (JarClassLoader loader = loaderFor(missing)) + { + assertThatThrownBy(() -> loader.loadClass(PACKAGE_NAME + "F_Payload_Z")) + .isInstanceOf(ClassNotFoundException.class) + .hasMessageContaining("F_Payload_Z"); + + Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(loader::getMainClassName); + // getMainClassName declares IOException; an absent file is precisely that case, and + // the message must name the file so the user knows which plugin is missing. + assertThat(thrown).isInstanceOf(IOException.class); + assertThat(thrown).isNotInstanceOf(NullPointerException.class); + assertThat(thrown.getMessage()).isNotNull().contains("not-there.jar"); + } + } + + @Test + @DisplayName("a corrupt jar fails through the declared channels, never as a raw NPE") + void corruptJarFailsDiagnosably() throws Exception + { + byte[] garbage = new byte[4096]; + new Random(20260823L).nextBytes(garbage); + Path jar = tempDir.resolve("corrupt.jar"); + Files.write(jar, garbage); + + try (JarClassLoader loader = loaderFor(jar)) + { + assertThatThrownBy(() -> loader.loadClass(PACKAGE_NAME + "F_Payload_Z")) + .isInstanceOf(ClassNotFoundException.class) + .hasMessageContaining("F_Payload_Z"); + + Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(loader::getMainClassName); + // Either the loader reports "no main class" or it reports an IOException; what it may + // not do is die on a null it never checked. + assertThat(thrown).isNotInstanceOf(NullPointerException.class); + if (thrown != null) + { + assertThat(thrown).isInstanceOf(IOException.class); + assertThat(thrown.getMessage()).isNotNull().isNotEmpty(); + } + } + } + + @Test + @DisplayName("Main-Class round trips through the manifest, and its absence reads as null") + void mainClassNameRoundTrips() throws Exception + { + Manifest withMain = new Manifest(); + withMain.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + withMain.getMainAttributes().put(Attributes.Name.MAIN_CLASS, PACKAGE_NAME + "F_Payload_Z"); + Path jar = buildJar("with-main.jar", + Map.of(PACKAGE_PATH + "F_Payload_Z.class", renamedPayload("F_Payload_Z")), withMain); + + try (JarClassLoader loader = loaderFor(jar)) + { + // JAR File Specification: the launcher entry point is the Main-Class attribute of the + // manifest's main section. Reading it back must return what was written. + assertThat(loader.getMainClassName()).isEqualTo(PACKAGE_NAME + "F_Payload_Z"); + } + + Manifest withoutMain = new Manifest(); + withoutMain.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + Path plain = buildJar("no-main.jar", + Map.of(PACKAGE_PATH + "F_Payload_Z.class", renamedPayload("F_Payload_Z")), withoutMain); + try (JarClassLoader loader = loaderFor(plain)) + { + // Documented: null means "no Main-Class attribute was defined". + assertThat(loader.getMainClassName()).isNull(); + } + } + + @Test + @DisplayName("invokeClass reports a missing class and a missing main method through its declared exceptions") + void invokeClassFailuresAreDeclaredAndNamed() throws Exception + { + Path jar = payloadJar("invoke.jar", "F_Payload_Z"); + try (JarClassLoader loader = loaderFor(jar)) + { + // Both are declared on invokeClass, so both must be what actually escapes. + assertThatThrownBy(() -> loader.invokeClass("com.example.NotHere", new String[0])) + .isInstanceOf(ClassNotFoundException.class) + .hasMessageContaining("com.example.NotHere"); + + assertThatThrownBy(() -> loader.invokeClass(PACKAGE_NAME + "F_Payload_Z", new String[0])) + .isInstanceOf(NoSuchMethodException.class) + .hasMessageContaining("main"); + } + } + + @Test + @DisplayName("an exception thrown by a plugin's main is wrapped, not swallowed") + void invokeClassIsDeclaredToWrapApplicationFailures() + { + // Structural property of the declared contract: the loader promises to surface an + // application failure as InvocationTargetException rather than letting it vanish, and + // reflective invocation is the mechanism that guarantees it. + assertThat(JarClassLoader.class.getDeclaredMethods()) + .filteredOn(m -> m.getName().equals("invokeClass")) + .allSatisfy(m -> assertThat(m.getExceptionTypes()).contains(InvocationTargetException.class)); + } + + @Test + @DisplayName("the loader is closeable and closing it stops it serving new classes") + void loaderIsCloseableAndStopsServingAfterClose() throws Exception + { + Path jar = payloadJar("closeable.jar", "F_Payload_Z", "F_Payload_Y"); + JarClassLoader loader = loaderFor(jar); + + // Ownership of an OS resource obliges the owner to expose a release operation. + assertThat(Closeable.class).isAssignableFrom(JarClassLoader.class); + + loader.loadClass(PACKAGE_NAME + "F_Payload_Z"); + assertThatCode(loader::close).doesNotThrowAnyException(); + // URLClassLoader.close is specified to make subsequent loads of not-yet-loaded classes + // fail; a loader that kept serving after close would still be holding the jar open. + assertThatThrownBy(() -> loader.loadClass(PACKAGE_NAME + "F_Payload_Y")) + .isInstanceOf(ClassNotFoundException.class); + // Closing twice is idempotent, as Closeable requires. + assertThatCode(loader::close).doesNotThrowAnyException(); + } + + @Test + @DisplayName("closing a loader that loaded classes releases the jar's file descriptor") + void closeReleasesDescriptorsTakenByClassLoading() throws Exception + { + Path jar = payloadJar("fd-load.jar", "F_Payload_Z"); + JarClassLoader loader = loaderFor(jar); + loader.loadClass(PACKAGE_NAME + "F_Payload_Z"); + loader.close(); + + // After the owner has been closed, no descriptor for the jar may remain: on a long-lived + // editor session every plugin rescan would otherwise cost a descriptor permanently, and + // the file could not be replaced on platforms with mandatory locking. + assertThat(openDescriptorsFor(jar)).as("descriptors still open on %s after close()", jar).isEmpty(); + assertThatCode(() -> Files.delete(jar)).doesNotThrowAnyException(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("reading the manifest does not leak a file handle past close()") + void mainClassLookupDoesNotLeakADescriptor() throws Exception + { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, PACKAGE_NAME + "F_Payload_Z"); + Path jar = buildJar("fd-manifest.jar", + Map.of(PACKAGE_PATH + "F_Payload_Z.class", renamedPayload("F_Payload_Z")), manifest); + + JarClassLoader loader = loaderFor(jar); + loader.getMainClassName(); + loader.close(); + + // Same ownership rule as above: whichever internal mechanism opened the jar, closing the + // object the caller was handed must release it. A handle that outlives close() is a leak + // no caller can clean up. + assertThat(openDescriptorsFor(jar)).as("descriptors still open on %s after close()", jar).isEmpty(); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/SheetExceptionFactoryTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/SheetExceptionFactoryTest.java new file mode 100644 index 0000000..4053bbf --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/SheetExceptionFactoryTest.java @@ -0,0 +1,210 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Property-based tests for {@link SheetExceptionFactory} and the exception types it produces. + * + *

THEORY. A diagnostic factory is an injection from a fault description into a message: the + * message is the only artefact that survives to the user, so every argument the caller was asked + * to supply must be recoverable from it. If a parameter cannot be observed in the output, the + * caller was made to compute a value for nothing and the user is shown an incomplete diagnosis. + * + *

The factory methods are enumerated reflectively rather than by hand, so a factory method + * added tomorrow is covered by these properties automatically. + */ +public class SheetExceptionFactoryTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private static List factoryMethods() + { + List methods = new ArrayList<>(); + for (Method method : SheetExceptionFactory.class.getDeclaredMethods()) + { + if (method.isSynthetic() || !Modifier.isStatic(method.getModifiers()) || !Modifier.isPublic(method.getModifiers())) + continue; + if (!Throwable.class.isAssignableFrom(method.getReturnType())) + continue; + methods.add(method); + } + methods.sort((a, b) -> a.getName().compareTo(b.getName())); + return methods; + } + + /** A distinct, greppable argument value per parameter position, so a drop is attributable. */ + private static Object argumentFor(Class type, int position) + { + if (type == Class.class) + return SheetExceptionFactoryTest.class; + if (type == String.class) + return "MARKER" + position + "VALUE"; + if (type == int.class || type == Integer.class) + return 4200 + position; + if (type == long.class || type == Long.class) + return 4200L + position; + if (type == boolean.class || type == Boolean.class) + return Boolean.TRUE; + throw new IllegalStateException("no argument recipe for parameter type " + type + + "; extend argumentFor() so the reflective coverage stays complete"); + } + + /** The text the caller must be able to find in the message for the argument at this position. */ + private static String expectedTextFor(Object argument) + { + if (argument instanceof Class clazz) + return clazz.getSimpleName(); + return String.valueOf(argument); + } + + @Test + @DisplayName("the reflective enumeration actually finds factory methods") + void enumerationIsNotEmpty() + { + // Guards the tests below from passing vacuously if the class is renamed or emptied. + assertThat(factoryMethods()).as("public static Throwable-returning factory methods").isNotEmpty(); + } + + @Test + @DisplayName("every factory method yields a non-null unchecked SheetException with a message") + void everyFactoryMethodIsTotal() throws Exception + { + SoftAssertions soft = new SoftAssertions(); + for (Method method : factoryMethods()) + { + Class[] parameterTypes = method.getParameterTypes(); + Object[] arguments = new Object[parameterTypes.length]; + for (int i = 0; i < parameterTypes.length; i++) + arguments[i] = argumentFor(parameterTypes[i], i); + + Object produced = method.invoke(null, arguments); + + // Totality: a factory whose whole job is to build a diagnostic may never return null, + // and an exception with no message is a dead end for whoever has to read the log. + soft.assertThat(produced).as("%s must produce an exception", method.getName()).isNotNull(); + if (produced == null) + continue; + + soft.assertThat(produced).as("%s return type", method.getName()).isInstanceOf(SheetException.class); + // SheetException extends RuntimeException, so it is unchecked; callers rely on being + // able to throw it from the Swing/table code paths that declare no checked exceptions. + soft.assertThat(produced).as("%s must be unchecked", method.getName()).isInstanceOf(RuntimeException.class); + soft.assertThat(((Throwable) produced).getMessage()) + .as("%s message", method.getName()) + .isNotNull() + .isNotEmpty(); + } + soft.assertAll(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("every argument a factory method demands appears in the message it produces") + void everyArgumentIsRecoverableFromTheMessage() throws Exception + { + SoftAssertions soft = new SoftAssertions(); + for (Method method : factoryMethods()) + { + Class[] parameterTypes = method.getParameterTypes(); + Object[] arguments = new Object[parameterTypes.length]; + for (int i = 0; i < parameterTypes.length; i++) + arguments[i] = argumentFor(parameterTypes[i], i); + + String message = ((Throwable) method.invoke(null, arguments)).getMessage(); + + for (int i = 0; i < arguments.length; i++) + { + // An argument that never reaches the message is information the caller was forced + // to gather and the user never gets to see - the offending value, the row number + // or the kind of value are exactly what makes a spreadsheet error actionable. + soft.assertThat(message) + .as("%s: parameter #%d (%s %s) must appear in the message <%s>", + method.getName(), i, parameterTypes[i].getSimpleName(), + method.getParameters()[i].getName(), message) + .contains(expectedTextFor(arguments[i])); + } + } + soft.assertAll(); + } + + @Test + @DisplayName("the message identifies the reporting editor and the line number distinctly") + void messageIdentifiesEditorAndLine() + { + SheetException e = SheetExceptionFactory.generateInvalidNameSheetException( + SheetExceptionFactoryTest.class, "move", "Hyprr Beam", "species", "Bulbasaur", 37); + + // The user is looking at a spreadsheet: without the editor name and the row number the + // message cannot be acted on, regardless of how the rest of the sentence is worded. + assertThat(e.getMessage()).contains("SheetExceptionFactoryTest"); + assertThat(e.getMessage()).contains("37"); + assertThat(e.getMessage()).contains("Hyprr Beam"); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("trimming the factory frame removes exactly one frame and fabricates none") + void stackTraceTrimRemovesExactlyOneFrame() + { + SheetException produced = SheetExceptionFactory.generateMissingValueSheetException( + SheetExceptionFactoryTest.class, "move", "level", "Tackle", "species", "Bulbasaur", 12); + Throwable reference = new Throwable(); + + StackTraceElement[] trimmed = produced.getStackTrace(); + StackTraceElement[] expected = reference.getStackTrace(); + + // Both throwables are created in this method, so their traces share the same suffix; the + // factory's own frame sits on top of the produced one. Deleting that frame must shorten + // the trace by exactly one element - a trace that keeps its length is claiming a call that + // never happened, and a duplicated frame misdirects whoever reads the log. + assertThat(trimmed.length).as("frame count after trimming the factory frame").isEqualTo(expected.length); + + // The deepest frame of a thread's stack has no caller below it, so it cannot appear twice + // in a row: a trace ending in two identical frames has had one fabricated by the trim. + assertThat(trimmed[trimmed.length - 1]) + .as("the deepest frame must not be duplicated") + .isNotEqualTo(trimmed[trimmed.length - 2]); + + // The frame that was removed must be the factory's own, and it must be the only one gone. + assertThat(trimmed[0].getClassName()).isEqualTo(SheetExceptionFactoryTest.class.getName()); + assertThat(trimmed).noneMatch(frame -> frame.getClassName().equals(SheetExceptionFactory.class.getName())); + } + + @Test + @DisplayName("the sheet exception types are unchecked and preserve their message") + void exceptionTypesArePlainCarriers() + { + // Identity law for a message carrier: what goes into the constructor comes out of + // getMessage() unchanged. + assertThat(new SheetException("boom").getMessage()).isEqualTo("boom"); + assertThat(new InvalidStringException("bad string").getMessage()).isEqualTo("bad string"); + + // Both are unchecked, matching how the sheet/table code throws them from methods that + // declare no checked exceptions. + assertThat(RuntimeException.class).isAssignableFrom(SheetException.class); + assertThat(RuntimeException.class).isAssignableFrom(InvalidStringException.class); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/framework/XmlReaderTest.java b/src/test/java/io/github/turtleisaac/pokeditor/framework/XmlReaderTest.java new file mode 100644 index 0000000..039ef30 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/framework/XmlReaderTest.java @@ -0,0 +1,241 @@ +package io.github.turtleisaac.pokeditor.framework; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +/** + * Property-based tests for {@link XmlReader}. + * + *

THEORY. A reader for a documented format is a partial function from byte sequences to values, + * and it owes its callers two things: + *

    + *
  • Totality of failure. For every input outside the accepted language the reader must + * fail through its declared channel - here {@code throws IOException} - carrying a message. + * A {@code NullPointerException}, an index-out-of-bounds or a {@code StackOverflowError} + * escaping from a parser means the input drove the implementation off its own rails, and + * tells the caller nothing about which input was bad.
  • + *
  • Fidelity. For every document in the accepted language, the values read back must be + * the values the document states. The expectations below are written from the XML 1.0 + * grammar (an element is {@code content}; end tags are spelled with a forward + * slash, XML 1.0 s.3.1), not from the implementation.
  • + *
+ */ +public class XmlReaderTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + @TempDir + Path tempDir; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private Path write(String name, String content) throws IOException + { + Path path = tempDir.resolve(name); + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + return path; + } + + /** The hostile-input battery. Keys are case names so a failure names the offending input. */ + private Map hostileInputs() + { + Map cases = new LinkedHashMap<>(); + cases.put("empty", "".getBytes(StandardCharsets.UTF_8)); + cases.put("whitespace-only", " \n \t \n".getBytes(StandardCharsets.UTF_8)); + cases.put("blank-line-where-root-belongs", "\n\n1\n".getBytes(StandardCharsets.UTF_8)); + cases.put("unclosed-root", "\n\n 1\n".getBytes(StandardCharsets.UTF_8)); + cases.put("mismatched-tags", "\n\n 1\n\n".getBytes(StandardCharsets.UTF_8)); + cases.put("unescaped-ampersand", "\n\n Tom & Jerry\n\n".getBytes(StandardCharsets.UTF_8)); + cases.put("duplicate-attribute", "\n\n v\n\n".getBytes(StandardCharsets.UTF_8)); + cases.put("truncated-mid-tag", "\n\n 1\n 2\n\n 1\n\n".getBytes(StandardCharsets.UTF_8)); + cases.put("deeply-nested-10000", deeplyNested(10_000).getBytes(StandardCharsets.UTF_8)); + cases.put("binary-garbage", new byte[] {(byte) 0xFF, (byte) 0xFE, (byte) 0xC3, 0x28, (byte) 0x80, + (byte) 0xA0, 0x00, 0x01, 0x02, (byte) 0xF8, (byte) 0x88, 0x0A, (byte) 0xED, (byte) 0xA0, (byte) 0x80}); + return cases; + } + + private static String deeplyNested(int depth) + { + StringBuilder sb = new StringBuilder("\n"); + for (int i = 0; i < depth; i++) + sb.append("\n"); + sb.append(" value\n"); + for (int i = depth - 1; i >= 0; i--) + sb.append("\n"); + return sb.toString(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("hostile input never escapes as a raw runtime failure") + void hostileInputFailsDiagnosably() throws IOException + { + SoftAssertions soft = new SoftAssertions(); + int caseIndex = 0; + for (Map.Entry entry : hostileInputs().entrySet()) + { + Path path = tempDir.resolve("hostile" + (caseIndex++) + ".xml"); + Files.write(path, entry.getValue()); + + Throwable thrown = catchThrowable(() -> new XmlReader(path).readFile()); + if (thrown == null) + continue; // accepting an input is a fidelity question, checked separately + + // The reader declares IOException; anything else is an implementation detail leaking + // out. NPE / index-out-of-bounds / StackOverflowError in particular identify no input. + soft.assertThat(thrown) + .as("case '%s' must fail through a declared, diagnosable channel", entry.getKey()) + .isNotInstanceOf(NullPointerException.class) + .isNotInstanceOf(IndexOutOfBoundsException.class) + .isNotInstanceOf(StackOverflowError.class); + soft.assertThat(thrown.getMessage()) + .as("case '%s' must carry a diagnostic message", entry.getKey()) + .isNotNull() + .isNotEmpty(); + } + soft.assertAll(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("documents that violate XML well-formedness are rejected, not half-read") + void notWellFormedDocumentsAreRejected() throws IOException + { + Map cases = new LinkedHashMap<>(); + // XML 1.0 s.2.1: a well-formed document has exactly one root element whose start tag is + // matched by an end tag. Returning a value for a document that never closes its root means + // a truncated file is indistinguishable from a complete one - the caller silently loses + // every element that was cut off. + cases.put("unclosed-root", "\n\n 1\n"); + cases.put("truncated-mid-element", "\n\n 1\n 2 entry : cases.entrySet()) + { + Path path = write(entry.getKey() + ".xml", entry.getValue()); + Throwable thrown = catchThrowable(() -> new XmlReader(path).readFile()); + soft.assertThat(thrown).as("case '%s' must be rejected", entry.getKey()).isNotNull(); + } + soft.assertAll(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("a well-formed document round trips: every element maps to the text it contains") + void wellFormedDocumentRoundTrips() throws IOException + { + // Written by hand against the XML 1.0 grammar so the expectation comes from the document, + // not from the parser: a root element "settings" containing three child elements, each + // holding character data. Child indentation is two spaces, the layout this reader slices + // its element names out of. + String document = + "\n" + + "\n" + + " /home/user/roms/heartgold.nds\n" + + " Darcula\n" + + " Learnsets\n" + + "\n"; + Path path = write("well-formed.xml", document); + + HashMap values = new XmlReader(path).readFile(); + + // Fidelity: the reader must reproduce the document's own element-to-content mapping. + assertThat(values).containsEntry("romPath", "/home/user/roms/heartgold.nds"); + assertThat(values).containsEntry("theme", "Darcula"); + assertThat(values).containsEntry("lastEditor", "Learnsets"); + } + + @Test + @DisplayName("an external entity declared in the DOCTYPE is never resolved") + void externalEntitiesAreNotResolved() throws IOException + { + String canary = "CANARY_" + UUID.randomUUID().toString().replace("-", "") + "_SECRET"; + Path secret = write("secret.txt", canary + "\n"); + + // Classic XXE payload (OWASP XML External Entity): if the reader expands SYSTEM entities, + // the contents of an arbitrary local file end up in the parsed values, which is a file + // disclosure vulnerability whenever the document is attacker-supplied. + String document = + "\n" + + " ]>\n" + + "\n" + + " &xxe;\n" + + "\n"; + Path path = write("xxe.xml", document); + + HashMap values = null; + Throwable thrown = null; + try + { + values = new XmlReader(path).readFile(); + } + catch (Throwable t) + { + thrown = t; + } + + // Whatever the reader does with the document, the secret file's contents must not appear + // in any value it hands back, nor in the diagnostics it throws. + if (values != null) + { + assertThat(values.values()).as("no parsed value may contain the external file's contents") + .noneMatch(v -> v != null && v.contains(canary)); + assertThat(values.keySet()).noneMatch(k -> k != null && k.contains(canary)); + } + if (thrown != null && thrown.getMessage() != null) + assertThat(thrown.getMessage()).doesNotContain(canary); + } + + @Test + @DisplayName("a 10,000-deep document does not overflow the stack") + void deepNestingDoesNotOverflowTheStack() throws IOException + { + Path path = write("deep.xml", deeplyNested(10_000)); + // Input-proportional recursion is a denial-of-service and an undiagnosable failure mode; + // document depth is attacker-controlled, so it must not map onto Java stack depth. + Throwable thrown = catchThrowable(() -> new XmlReader(path).readFile()); + assertThat(thrown).isNotInstanceOf(StackOverflowError.class); + } + + @Test + @DisplayName("a missing file fails with an IOException naming the file") + void missingFileIsReportedWithItsName() + { + Path missing = tempDir.resolve("definitely-not-here.xml"); + // The declared failure channel, with the one piece of context the caller cannot recover + // on its own if the message omits it. + assertThatThrownBy(() -> new XmlReader(missing).readFile()) + .isInstanceOf(IOException.class) + .hasMessageContaining("definitely-not-here.xml"); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/EditorComboBoxTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/EditorComboBoxTest.java new file mode 100644 index 0000000..e25d98c --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/EditorComboBoxTest.java @@ -0,0 +1,205 @@ +package io.github.turtleisaac.pokeditor.gui; + +import io.github.turtleisaac.pokeditor.gui.EditorComboBox.ComboBoxItem; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.DefaultComboBoxModel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Property-based tests for {@link EditorComboBox}. + * + *

THEORY. A combo box built from a list of names is an order-preserving bijection between the + * index range {@code [0, n)} and the supplied names: index i must display name i, and the selection + * accessors must be mutual inverses of each other ({@code getSelectedIndex()} and + * {@code getSelectedItem()} always describe the same element). Every editor in this application + * converts a user-visible name back into a numeric id through exactly this mapping, so an + * off-by-one, a silent de-duplication or a dropped entry writes the wrong id into the ROM. + */ +public class EditorComboBoxTest +{ + private static final String[] NAMES = {"Bulbasaur", "Ivysaur", "Venusaur", "Charmander", "Charmeleon"}; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @Test + @DisplayName("the model is exactly the supplied list, in order, with duplicates kept") + void modelMirrorsTheSuppliedList() + { + String[] items = {"Alpha", "Beta", "Alpha", "", "Gamma"}; + EditorComboBox box = new EditorComboBox(items); + + // Multiplicity and order are part of the mapping: entry i of the ROM's name table is at + // index i. De-duplicating "Alpha" or dropping the empty name would shift every later id. + assertThat(box.getItemCount()).isEqualTo(items.length); + for (int i = 0; i < items.length; i++) + assertThat(box.getItemAt(i).toString()).as("index %d", i).isEqualTo(items[i]); + } + + @Test + @DisplayName("index and value are mutual inverses across the whole range, ends included") + void indexAndValueAreMutualInverses() + { + EditorComboBox box = new EditorComboBox(NAMES); + + for (int i = 0; i < NAMES.length; i++) + { + box.setSelectedIndex(i); + // index -> value -> index must be the identity on [0, n), including the two ends, + // which is where an off-by-one in a name/id map first shows up. + assertThat(box.getSelectedIndex()).as("round trip through index %d", i).isEqualTo(i); + assertThat(box.getSelectedItem()).isSameAs(box.getItemAt(i)); + assertThat(box.getSelectedItem().toString()).isEqualTo(NAMES[i]); + } + + for (int i = 0; i < NAMES.length; i++) + { + box.setSelectedItem(box.getItemAt(i)); + // value -> index -> value is the other half of the bijection. + assertThat(box.getSelectedIndex()).as("round trip through item %d", i).isEqualTo(i); + } + } + + @Test + @DisplayName("a non-empty list starts with its first entry selected") + void constructionSelectsTheFirstEntry() + { + // JComboBox(E[]) is specified to select the first item; editors rely on the component + // never starting in an indeterminate state. + EditorComboBox box = new EditorComboBox(NAMES); + assertThat(box.getSelectedIndex()).isZero(); + assertThat(box.getSelectedItem()).isSameAs(box.getItemAt(0)); + } + + @Test + @DisplayName("an out-of-range index is rejected and leaves the selection intact") + void outOfRangeSelectionIsRejectedWithoutCorruptingState() + { + EditorComboBox box = new EditorComboBox(NAMES); + box.setSelectedIndex(2); + + // JComboBox.setSelectedIndex is specified to throw IllegalArgumentException outside + // [-1, itemCount); a clamp would silently write a neighbouring id instead. + assertThatThrownBy(() -> box.setSelectedIndex(NAMES.length)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> box.setSelectedIndex(-2)).isInstanceOf(IllegalArgumentException.class); + + // Failure atomicity: a rejected command leaves the component readable and unchanged. + assertThat(box.getSelectedIndex()).isEqualTo(2); + assertThat(box.getSelectedItem()).isSameAs(box.getItemAt(2)); + } + + @Test + @DisplayName("index -1 is the documented 'no selection' sentinel") + void minusOneClearsTheSelection() + { + EditorComboBox box = new EditorComboBox(NAMES); + box.setSelectedIndex(-1); + // -1 <-> null is the documented sentinel pair; both accessors must agree on it. + assertThat(box.getSelectedIndex()).isEqualTo(-1); + assertThat(box.getSelectedItem()).isNull(); + } + + @Test + @DisplayName("an empty combo box is constructible and reads as unselected") + void emptyComboBoxIsWellDefined() + { + assertThatCode(EditorComboBox::new).doesNotThrowAnyException(); + + EditorComboBox empty = new EditorComboBox(); + // With an empty index range there is no valid index, so the sentinel is the only possible + // answer, and reading it must not throw. + assertThat(empty.getItemCount()).isZero(); + assertThat(empty.getSelectedIndex()).isEqualTo(-1); + assertThat(empty.getSelectedItem()).isNull(); + + EditorComboBox fromEmptyArray = new EditorComboBox(new String[0]); + assertThat(fromEmptyArray.getItemCount()).isZero(); + assertThat(fromEmptyArray.getSelectedIndex()).isEqualTo(-1); + assertThat(fromEmptyArray.getSelectedItem()).isNull(); + } + + @Test + @DisplayName("all four constructors produce the same mapping for the same names") + void allConstructorsAgreeOnTheMapping() + { + ComboBoxItem[] items = new ComboBoxItem[NAMES.length]; + for (int i = 0; i < NAMES.length; i++) + items[i] = new ComboBoxItem(NAMES[i]); + + EditorComboBox fromStrings = new EditorComboBox(NAMES); + EditorComboBox fromItems = new EditorComboBox(items); + EditorComboBox fromModel = new EditorComboBox(new DefaultComboBoxModel<>(items)); + + // The constructors are different spellings of the same function from names to indices; + // they must not disagree, or the id a user picks depends on which overload built the box. + for (int i = 0; i < NAMES.length; i++) + { + assertThat(fromItems.getItemAt(i).toString()).as("index %d", i).isEqualTo(fromStrings.getItemAt(i).toString()); + assertThat(fromModel.getItemAt(i).toString()).as("index %d", i).isEqualTo(fromStrings.getItemAt(i).toString()); + } + assertThat(fromItems.getItemCount()).isEqualTo(NAMES.length); + assertThat(fromModel.getItemCount()).isEqualTo(NAMES.length); + } + + @Test + @DisplayName("selection stays self-consistent when set by value") + void selectionByValueKeepsIndexAndItemConsistent() + { + EditorComboBox box = new EditorComboBox(NAMES); + + // Selecting by value is how a table cell editor restores the current row's entry: it has + // the displayed name, not the item instance. Whatever the model does with a value equal to + // one of its entries, the two accessors must keep describing the same element - a non-null + // selected item with index -1 says "the selection is not in the list", which no caller can + // act on and which silently writes the wrong id back. + box.setSelectedItem(new ComboBoxItem("Venusaur")); + + int index = box.getSelectedIndex(); + Object selected = box.getSelectedItem(); + if (selected == null) + assertThat(index).isEqualTo(-1); + else + { + assertThat(index).as("selected item <%s> must be locatable in the model", selected).isNotNegative(); + assertThat(box.getItemAt(index).toString()).isEqualTo(selected.toString()); + } + } + + @Test + @DisplayName("ComboBoxItem renders exactly the text it was built from") + void comboBoxItemRendersItsValue() + { + // The rendered text is the only thing the user matches against, so it must be the identity + // on the string it was constructed with. + assertThat(new ComboBoxItem("Thunderbolt").toString()).isEqualTo("Thunderbolt"); + assertThat(new ComboBoxItem("").toString()).isEmpty(); + + // The int constructor is the decimal rendering of the number, over the whole int range. + for (int value : new int[] {0, 1, 9, 10, 151, -1, Integer.MAX_VALUE, Integer.MIN_VALUE}) + assertThat(new ComboBoxItem(value).toString()).as("value %d", value).isEqualTo(Integer.toString(value)); + } + + @Test + @DisplayName("renaming an item is visible through the model at the same index") + void renamingAnItemKeepsItsIndex() + { + EditorComboBox box = new EditorComboBox(NAMES); + box.getItemAt(1).setName("Renamed"); + + // A mutator/accessor round trip: the new name must be readable, and renaming must not + // move the entry, since the index is the id. + assertThat(box.getItemAt(1).toString()).isEqualTo("Renamed"); + assertThat(box.getItemCount()).isEqualTo(NAMES.length); + assertThat(box.getItemAt(0).toString()).isEqualTo(NAMES[0]); + assertThat(box.getItemAt(2).toString()).isEqualTo(NAMES[2]); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/JideLookAndFeelResolutionTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/JideLookAndFeelResolutionTest.java new file mode 100644 index 0000000..124e7ed --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/JideLookAndFeelResolutionTest.java @@ -0,0 +1,57 @@ +package io.github.turtleisaac.pokeditor.gui; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.LookAndFeel; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the one class this application must be able to resolve but never names. + *

+ * jide-oss predates the module system. {@code LookAndFeelFactory} decides which style to + * install by asking {@code lnf instanceof com.sun.java.swing.plaf.windows.WindowsLookAndFeel}, + * and it reaches that question on the branch taken for every look and feel it does not + * recognise - which includes FlatLaf, the one this application sets. Every JIDE component runs + * it, because {@code JidePopup.updateUI()} calls {@code installJideExtension()} and + * {@code updateUI()} runs from the JComponent constructor. This application gets there whenever + * someone types into a combo box in a sheet: {@link EditorComboBox} installs a + * {@code ComboBoxSearchable}, and its search popup is a {@code JidePopup}. + *

+ * An {@code instanceof} must resolve its class before it can answer false, so an + * absent class is not a quiet no - it is a {@code NoClassDefFoundError} thrown out of a + * constructor. No JDK ships that package on Linux or macOS, which is what the system-scoped + * {@code WinLaF.jar} at the repository root exists to supply. Nothing references it by name, so + * without this test it can be dropped as an obvious piece of dead weight and the failure only + * appears when a user types a move name. + *

+ * What this test cannot cover. On Windows the JDK does ship the package, inside + * {@code java.desktop}, which does not export it. Parent-first delegation finds that copy and + * shadows {@code WinLaF.jar}, so resolution fails there with {@code IllegalAccessError} no + * matter what is on the classpath; the fix is the {@code Add-Exports} manifest entry written by + * the {@code dist} profile. A test running inside one JVM cannot check the manifest of a jar + * that profile has not built yet, so that half is verified by the profile configuration and + * documented in {@code TECH_DEBT.md} rather than asserted here. + */ +class JideLookAndFeelResolutionTest +{ + private static final String WINDOWS_LAF = "com.sun.java.swing.plaf.windows.WindowsLookAndFeel"; + + @Test + @DisplayName("the look and feel class JIDE resolves is on the application's classpath") + void windowsLookAndFeelIsResolvable() throws Exception + { + // the loader JIDE's own classes are defined by, so this asks the question the way the + // failing instanceof asks it rather than the way a test happens to be launched + ClassLoader loader = com.jidesoft.swing.ComboBoxSearchable.class.getClassLoader(); + + Class laf = Class.forName(WINDOWS_LAF, false, loader); + + assertThat(LookAndFeel.class).as( + "%s must be a LookAndFeel - JIDE tests the installed look and feel against it " + + "with instanceof, which is a link error rather than a false answer if the " + + "class on the classpath is not related to it", WINDOWS_LAF) + .isAssignableFrom(laf); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/DataEditorContractTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/DataEditorContractTest.java new file mode 100644 index 0000000..84bdba6 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/DataEditorContractTest.java @@ -0,0 +1,413 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data; + +import io.github.turtleisaac.pokeditor.formats.BytesDataContainer; +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.E_Entry; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.FormatModel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * {@code DefaultDataEditor} is the base every non-sheet editor extends. It is a tiny class, and + * almost all of it is contract rather than behaviour: + * + *

    + *
  • A selection sentinel. {@code selectedIndex} starts at {@code -1}, which is not an + * index - it is the encoding of "nothing is selected". The distinction matters because every + * other method in the class indexes the entry list with it: a class which started at 0 would + * address entry 0 (a real, editable Pokemon/script/sprite) before the user had chosen + * anything, and a copy or a paste would silently hit the wrong entry.
  • + *
  • Copy and paste are inverses. {@code applyCopiedEntry(writeSelectedEntryForCopy())} + * is the identity on the selected entry - that is what makes it a copy rather than a + * transform - and it is a no-op on every other entry.
  • + *
  • Totality. {@code getEnabledToolbarButtons()} is iterated by the panel without a null + * check, and {@code getPanel()} documents null as its own "not attached yet" answer.
  • + *
+ * + * {@code DefaultDataEditorPanel} needs a {@code PokeditorManager} and therefore a real ROM, so it + * is out of reach here; {@code EditorDataModel} is an interface, which is what makes the editor + * itself fully constructible headlessly. + */ +public class DataEditorContractTest +{ + private static final int ENTRY_WIDTH = 3; + private static final int ENTRY_COUNT = 4; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private static List entries() + { + List list = new ArrayList<>(); + for (int index = 0; index < ENTRY_COUNT; index++) + { + E_Entry entry = new E_Entry(ENTRY_WIDTH); + for (int cell = 0; cell < ENTRY_WIDTH; cell++) + entry.set(cell, 100 * (index + 1) + cell); + list.add(entry); + } + return list; + } + + private static List payloads(List data) + { + List snapshot = new ArrayList<>(); + for (E_Entry entry : data) + snapshot.add(Arrays.toString(entry.snapshot())); + return snapshot; + } + + // ---------------------------------------------------------------- selection state machine + + @Test + @DisplayName("selectedIndex starts at the no-selection sentinel, not at entry 0") + void selectedIndexStartsAtTheSentinel() + { + E_Editor editor = new E_Editor(new E_FormatModel(entries())); + + // PROPERTY: "no entry chosen" is a distinct state from "entry 0 chosen", and the class has + // to be able to represent it. -1 is the sentinel this class picks; the requirement is only + // that the initial value is NOT a valid index into a non-empty entry list, because a valid + // one would make the two states indistinguishable and let a copy/paste address a real entry + // the user never selected. + assertThat(editor.getSelectedIndex() >= 0 && editor.getSelectedIndex() < editor.getModel().getEntryCount()) + .as("the initial selection must not be a valid entry index") + .isFalse(); + assertThat(editor.getSelectedIndex()).isEqualTo(-1); + } + + @Test + @DisplayName("selection tracks the last change and is idempotent") + void selectionTracksTheLastChange() + { + E_Editor editor = new E_Editor(new E_FormatModel(entries())); + + editor.selectedIndexedChanged(2, null); + assertThat(editor.getSelectedIndex()).isEqualTo(2); + + // PROPERTY (idempotence): selecting what is already selected is a no-op - the state is the + // selection itself, not a history of selections. + editor.selectedIndexedChanged(2, null); + assertThat(editor.getSelectedIndex()).isEqualTo(2); + + // PROPERTY (last-write-wins): the selection is a single value, so a sequence of changes + // leaves exactly the last one in effect. + editor.selectedIndexedChanged(0, null); + editor.selectedIndexedChanged(3, null); + assertThat(editor.getSelectedIndex()).isEqualTo(3); + } + + // ---------------------------------------------------------------- panel attachment + + @Test + @DisplayName("getPanel() is null until a panel is attached") + void panelIsNullUntilAttached() + { + E_Editor editor = new E_Editor(new E_FormatModel(entries())); + + // PROPERTY: the Javadoc defines null as "this editor has not been added to a panel". A + // freshly constructed editor has not been, so null is the only answer consistent with it. + assertThat(editor.getPanel()).isNull(); + } + + @Test + @DisplayName("getEnabledToolbarButtons() is never null") + void enabledToolbarButtonsIsTotal() + { + E_Editor editor = new E_Editor(new E_FormatModel(entries())); + + // PROPERTY (totality of the contract): DefaultDataEditorPanel calls + // enabledToolbarButtons.contains(..) six times in a row with no null check, so the return + // value is required to be a set - possibly empty, never absent. "No buttons" is the empty + // set; null is not a value in the codomain. + Set buttons = editor.getEnabledToolbarButtons(); + assertThat(buttons).isNotNull(); + assertThatCode(() -> buttons.contains(DefaultDataEditorPanel.DataEditorButtons.ADD_ENTRY)) + .doesNotThrowAnyException(); + } + + // ---------------------------------------------------------------- model type discrimination + + @Test + @DisplayName("copying from a non-FormatModel editor yields null") + void copyFromNonFormatModelYieldsNull() + { + E_Editor editor = new E_Editor(new E_PlainModel(ENTRY_COUNT)); + editor.selectedIndexedChanged(1, null); + + // PROPERTY: the copy path is defined only for models which expose a GenericFileData list. + // For any other model there is nothing to serialise, so the answer is "no clipboard + // content" - null - rather than a partially formed container. + assertThat(editor.writeSelectedEntryForCopy()).isNull(); + } + + @Test + @DisplayName("pasting into a non-FormatModel editor changes nothing") + void pasteIntoNonFormatModelIsANoOp() + { + E_PlainModel model = new E_PlainModel(ENTRY_COUNT); + E_Editor editor = new E_Editor(model); + editor.selectedIndexedChanged(1, null); + List before = model.snapshot(); + + // PROPERTY (all-or-nothing): the same discrimination has to govern both directions. If a + // model cannot be copied FROM, it cannot be pasted INTO either, and the paste must leave it + // exactly as it was - a partial write here would be a corruption the user cannot see. + assertThatCode(() -> editor.applyCopiedEntry(new BytesDataContainer())) + .doesNotThrowAnyException(); + assertThat(model.snapshot()).isEqualTo(before); + } + + // ---------------------------------------------------------------- copy/paste round trip + + @Test + @DisplayName("applyCopiedEntry(writeSelectedEntryForCopy()) is the identity on the selected entry") + void copyPasteRoundTripIsTheIdentity() + { + List data = entries(); + E_Editor editor = new E_Editor(new E_FormatModel(data)); + editor.selectedIndexedChanged(2, null); + + List original = payloads(data); + BytesDataContainer copied = editor.writeSelectedEntryForCopy(); + + // scribble over the selected entry so that "restored" cannot be confused with "never touched" + data.get(2).set(0, 999); + assertThat(payloads(data)).isNotEqualTo(original); + + editor.applyCopiedEntry(copied); + + // PROPERTY (inverse pair): save and setData are inverses on the format, so copy followed by + // paste into the same entry restores exactly the bytes that were read. Anything else means + // the clipboard round trip is lossy, and the user's "copy this Pokemon" quietly edits it. + assertThat(payloads(data)) + .as("copy then paste into the same entry must restore it exactly") + .isEqualTo(original); + } + + @Test + @DisplayName("pasting writes the selected entry and no other") + void pasteIsLocalToTheSelectedEntry() + { + List data = entries(); + E_Editor editor = new E_Editor(new E_FormatModel(data)); + + editor.selectedIndexedChanged(1, null); + BytesDataContainer copied = editor.writeSelectedEntryForCopy(); + String sourcePayload = Arrays.toString(data.get(1).snapshot()); + + List before = payloads(data); + editor.selectedIndexedChanged(3, null); + editor.applyCopiedEntry(copied); + + // PROPERTY (locality): a paste is an assignment to one entry. Entry 3 must become equal to + // entry 1, and entries 0, 1 and 2 must be fixed points of the operation - the same + // injectivity requirement a cell write in a sheet has to satisfy. + assertThat(Arrays.toString(data.get(3).snapshot())) + .as("the pasted-into entry must equal the copied-from entry") + .isEqualTo(sourcePayload); + for (int index : new int[] {0, 1, 2}) + { + assertThat(payloads(data).get(index)) + .as("entry %d must be untouched by a paste into entry 3", index) + .isEqualTo(before.get(index)); + } + } + + // ---------------------------------------------------------------- no selection + + @Test + @DisplayName("copying with nothing selected yields no clipboard content rather than an index error") + void copyWithNoSelectionYieldsNull() + { + List data = entries(); + E_Editor editor = new E_Editor(new E_FormatModel(data)); + + // PROPERTY: the sentinel exists precisely so that "nothing selected" can be handled. The + // copy path already has a well-defined answer for "there is nothing to copy" - it returns + // null when the model is the wrong kind - and "no entry is selected" is the same situation + // reached by a different route, so it must produce the same answer. Instead the sentinel is + // fed straight to getData().get(-1), which raises a raw IndexOutOfBoundsException at the + // user from a toolbar button they are allowed to press at any time. + assertThat(editor.writeSelectedEntryForCopy()) + .as("copying with no selection must yield no clipboard content") + .isNull(); + } + + @Test + @DisplayName("pasting with nothing selected changes nothing and raises nothing") + void pasteWithNoSelectionIsANoOp() + { + List data = entries(); + E_Editor editor = new E_Editor(new E_FormatModel(data)); + List before = payloads(data); + + // PROPERTY: with no entry selected there is no destination, so the paste has nothing to do + // and must do nothing. It must in particular not throw: applyCopiedEntry's own catch block + // reports failures with a hard-coded JOptionPane.showMessageDialog, which in a headless JVM + // throws HeadlessException from inside the catch - so the sentinel's IndexOutOfBoundsException + // is not contained, it is merely exchanged for a different escaping exception. (That the + // error path cannot be exercised at all without a display is itself a testability defect: + // the dialog is hard-coded rather than delegated to an injectable error reporter.) + assertThatCode(() -> editor.applyCopiedEntry(new BytesDataContainer())) + .as("pasting with no selection must be a no-op") + .doesNotThrowAnyException(); + assertThat(payloads(data)).isEqualTo(before); + } + + // ---------------------------------------------------------------- model swap + + @Test + @DisplayName("setModel replaces the model the copy path discriminates on") + void setModelReplacesTheModel() + { + E_PlainModel plain = new E_PlainModel(ENTRY_COUNT); + E_Editor editor = new E_Editor(plain); + assertThat(editor.getModel()).isSameAs(plain); + + // PROPERTY: getModel/setModel are a plain get/set pair, so get after set returns what was + // set. The copy path branches on the model's runtime type, so a stale model here would send + // copies to a list the editor is no longer showing. + E_FormatModel replacement = new E_FormatModel(entries()); + editor.setModel(replacement); + assertThat(editor.getModel()).isSameAs(replacement); + + editor.selectedIndexedChanged(0, null); + assertThat(editor.writeSelectedEntryForCopy()) + .as("after swapping in a FormatModel the copy path must use it") + .isNotNull(); + } + + // ---------------------------------------------------------------- doubles + + /** the property enum a real editor's model is parameterised by; this double needs only one */ + public enum E_Property + { + VALUE + } + + /** a concrete editor: the class under test is abstract in exactly two methods, both trivial */ + static class E_Editor extends DefaultDataEditor + { + E_Editor(EditorDataModel model) + { + super(model); + } + + @Override + public Class getDataClass() + { + return E_Entry.class; + } + + @Override + public Set getEnabledToolbarButtons() + { + return Set.of(DefaultDataEditorPanel.DataEditorButtons.COPY_ENTRY, + DefaultDataEditorPanel.DataEditorButtons.PASTE_ENTRY); + } + } + + /** an {@link EditorDataModel} which is deliberately NOT a {@code FormatModel} */ + static class E_PlainModel implements EditorDataModel + { + private final int[] values; + + E_PlainModel(int entryCount) + { + this.values = new int[entryCount]; + for (int index = 0; index < entryCount; index++) + values[index] = index * 7; + } + + List snapshot() + { + List flat = new ArrayList<>(); + for (int value : values) + flat.add(String.valueOf(value)); + return flat; + } + + @Override + public Object getValueFor(int entryIdx, E_Property property) + { + return values[entryIdx]; + } + + @Override + public void setValueFor(Object aValue, int entryIdx, E_Property property) + { + values[entryIdx] = Integer.parseInt(String.valueOf(aValue)); + } + + @Override + public int getEntryCount() + { + return values.length; + } + + @Override + public String getEntryName(int entryIdx) + { + return "entry " + entryIdx; + } + } + + /** the minimum {@code FormatModel} the copy path needs: a list of GenericFileData entries */ + static class E_FormatModel extends FormatModel + { + E_FormatModel(List data) + { + super(data, Collections.emptyList()); + } + + @Override + public int getColumnCount() + { + return 1; + } + + @Override + public String getColumnNameKey(int columnIndex) + { + return "id"; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + return getData().get(rowIndex).get(0); + } + + @Override + public Object getValueFor(int entryIdx, E_Property property) + { + return getData().get(entryIdx).get(0); + } + + @Override + public void setValueFor(Object aValue, int entryIdx, E_Property property) + { + getData().get(entryIdx).set(0, Integer.parseInt(String.valueOf(aValue))); + } + + @Override + public FormatModel getFrozenColumnModel() + { + return null; + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ElementRangeTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ElementRangeTest.java new file mode 100644 index 0000000..aaf7301 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ElementRangeTest.java @@ -0,0 +1,180 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import javax.swing.text.BadLocationException; + +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.newQuiescedDocument; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link ScriptDocument.ElementRange} is a half-open interval [min, maxExclusive) over document + * offsets. Every syntax colour, every tooltip and every ctrl-click target is expressed as one, so + * an off-by-one in this class is an off-by-one everywhere in the editor at once. These tests state + * the definition of a half-open interval and nothing else. + */ +class ElementRangeTest +{ + private ScriptDocument document; + + @BeforeEach + void setUp() + { + // ElementRange is an inner class; it needs an enclosing document, but none of the interval + // arithmetic below depends on that document's contents. + document = newQuiescedDocument(); + } + + @ParameterizedTest(name = "[{0},{1})") + @CsvSource({"0, 1", "0, 5", "3, 4", "7, 20", "100, 101"}) + @DisplayName("min is inside the range and min - 1 is outside it") + void lowerBoundIsInclusive(int min, int maxExclusive) + { + ScriptDocument.ElementRange range = document.new ElementRange(min, maxExclusive, null); + + assertThat(range.contains(min)) + .as("%d is the first offset of [%d,%d)", min, min, maxExclusive) + .isTrue(); + assertThat(range.contains(min - 1)) + .as("%d is before [%d,%d)", min - 1, min, maxExclusive) + .isFalse(); + } + + @ParameterizedTest(name = "[{0},{1})") + @CsvSource({"0, 1", "0, 5", "3, 4", "7, 20", "100, 101"}) + @DisplayName("maxExclusive - 1 is the last offset inside the range and maxExclusive is outside it") + void upperBoundIsExclusive(int min, int maxExclusive) + { + ScriptDocument.ElementRange range = document.new ElementRange(min, maxExclusive, null); + + // This is the assertion that the historical `value < maxExclusive - 1` bug broke: it + // dropped the final character of every token, so the last letter of a command never got + // its tooltip and never got styled with the rest of the word. + assertThat(range.contains(maxExclusive - 1)) + .as("%d is the last offset of [%d,%d)", maxExclusive - 1, min, maxExclusive) + .isTrue(); + assertThat(range.contains(maxExclusive)) + .as("%d is one past the end of [%d,%d)", maxExclusive, min, maxExclusive) + .isFalse(); + } + + @ParameterizedTest(name = "[{0},{1})") + @CsvSource({"0, 1", "0, 5", "3, 4", "7, 20", "100, 101"}) + @DisplayName("every offset from min to maxExclusive - 1 inclusive is contained, and no other") + void containsExactlyItsOwnOffsets(int min, int maxExclusive) + { + ScriptDocument.ElementRange range = document.new ElementRange(min, maxExclusive, null); + + for (int offset = Math.max(0, min - 3); offset < maxExclusive + 3; offset++) + { + boolean expected = offset >= min && offset < maxExclusive; + assertThat(range.contains(offset)) + .as("[%d,%d).contains(%d)", min, maxExclusive, offset) + .isEqualTo(expected); + } + } + + @ParameterizedTest(name = "[{0},{1}) has length {2}") + @CsvSource({"0, 1, 1", "0, 5, 5", "3, 4, 1", "7, 20, 13", "100, 101, 1"}) + @DisplayName("length is exactly maxExclusive - min") + void lengthIsTheWidthOfTheInterval(int min, int maxExclusive, int expectedLength) + { + // The document styles `getLength()` characters starting at `getMin()`. If length were + // maxExclusive - min + 1 the highlighter would paint one character beyond every token - + // which is exactly the bug ScriptPane used to carry. + assertThat(document.new ElementRange(min, maxExclusive, null).getLength()) + .isEqualTo(expectedLength); + } + + @Test + @DisplayName("length counts exactly the offsets the range contains") + void lengthAgreesWithContains() + { + ScriptDocument.ElementRange range = document.new ElementRange(4, 11, null); + + int contained = 0; + for (int offset = 0; offset < 40; offset++) + { + if (range.contains(offset)) + contained++; + } + + assertThat(contained).isEqualTo(range.getLength()); + } + + @ParameterizedTest(name = "[{0},{1})") + @CsvSource({"0, 0", "5, 5", "7, 3", "0, -1"}) + @DisplayName("a range that contains nothing cannot be constructed at all") + void emptyOrInvertedRangesAreRejected(int min, int maxExclusive) + { + // A zero-width range contains no offset, so styling or tooltipping it is meaningless. + // The class enforces that by refusing to exist, which is a stronger guarantee than + // "contains() returns false" - it means no such range can ever reach the range set. + assertThatThrownBy(() -> document.new ElementRange(min, maxExclusive, null)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("must be greater than"); + } + + @Test + @DisplayName("a range encloses itself and any sub-range of itself") + void containsNestedRanges() + { + ScriptDocument.ElementRange outer = document.new ElementRange(10, 20, null); + + assertThat(outer.contains(document.new ElementRange(10, 20, null))) + .as("a range encloses itself").isTrue(); + assertThat(outer.contains(document.new ElementRange(12, 15, null))) + .as("strictly inside").isTrue(); + assertThat(outer.contains(document.new ElementRange(10, 15, null))) + .as("flush with the start").isTrue(); + assertThat(outer.contains(document.new ElementRange(15, 20, null))) + .as("flush with the end").isTrue(); + } + + @Test + @DisplayName("a range does not enclose a range that crosses or exceeds its bounds") + void doesNotContainCrossingRanges() + { + ScriptDocument.ElementRange outer = document.new ElementRange(10, 20, null); + + assertThat(outer.contains(document.new ElementRange(5, 15, null))) + .as("overlaps the start").isFalse(); + assertThat(outer.contains(document.new ElementRange(15, 25, null))) + .as("overlaps the end").isFalse(); + assertThat(outer.contains(document.new ElementRange(5, 25, null))) + .as("strictly larger").isFalse(); + assertThat(outer.contains(document.new ElementRange(20, 30, null))) + .as("starts where this one ends").isFalse(); + assertThat(outer.contains(document.new ElementRange(1, 9, null))) + .as("entirely before").isFalse(); + } + + @Test + @DisplayName("a range's string form is exactly the document text it covers") + void toStringIsTheCoveredText() throws BadLocationException + { + // toString() is how a range identifies itself in a debugger, a log line or an error + // message; if it reports text the range does not cover, every such report misleads. + document.insertString(0, "WaitTime 5 0\nEnd\n", null); + + ScriptDocument.ElementRange range = document.new ElementRange(0, 8, null); + + assertThat(range.toString()).isEqualTo("WaitTime"); + } + + @Test + @DisplayName("a range that reaches the end of the document has a string form") + void toStringAtEndOfDocument() throws BadLocationException + { + document.insertString(0, "End", null); + + ScriptDocument.ElementRange range = document.new ElementRange(0, document.getLength(), null); + + assertThat(range.toString()).isEqualTo("End"); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentEditingTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentEditingTest.java new file mode 100644 index 0000000..6374e89 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentEditingTest.java @@ -0,0 +1,268 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.Fixture; +import io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.Interval; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.text.BadLocationException; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptDocumentHighlightingTest.assertRangesDoNotCross; +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptDocumentHighlightingTest.assertRangesLieInsideDocument; +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Edits are where a highlighter's offset arithmetic breaks, because every stored offset was + * computed against the text as it was before the edit. The first group of tests walks the + * boundaries (offset 0, offset getLength(), empty edits, deleting everything); the second checks + * that the range set never survives an edit in a state that describes offsets the document no + * longer has. + */ +class ScriptDocumentEditingTest +{ + private static final String SCRIPT = "WaitTime 5 0\nEnd\nEnd\n"; + + private ScriptDocument documentContaining(String text) throws BadLocationException + { + ScriptDocument document = newQuiescedDocument(); + document.insertString(0, text, null); + document.setSyntaxAttributes(); + return document; + } + + private void assertStillConsistent(ScriptDocument document) throws BadLocationException + { + document.setSyntaxAttributes(); + assertRangesLieInsideDocument(document); + assertRangesDoNotCross(document); + } + + @Test + @DisplayName("inserting at offset 0 keeps the text and the ranges consistent") + void insertAtStart() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + + assertThatCode(() -> document.insertString(0, "End\n", null)).doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo("End\n" + SCRIPT); + assertStillConsistent(document); + } + + @Test + @DisplayName("inserting at getLength() keeps the text and the ranges consistent") + void insertAtEnd() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + + assertThatCode(() -> document.insertString(document.getLength(), "End\n", null)) + .doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo(SCRIPT + "End\n"); + assertStillConsistent(document); + } + + @Test + @DisplayName("inserting in the middle keeps the text and the ranges consistent") + void insertInTheMiddle() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + int middle = SCRIPT.length() / 2; + + assertThatCode(() -> document.insertString(middle, "End\n", null)).doesNotThrowAnyException(); + + assertThat(textOf(document)) + .isEqualTo(SCRIPT.substring(0, middle) + "End\n" + SCRIPT.substring(middle)); + assertStillConsistent(document); + } + + @Test + @DisplayName("inserting an empty string is a no-op") + void insertEmptyString() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + List before = intervalsOf(document); + + assertThatCode(() -> document.insertString(0, "", null)).doesNotThrowAnyException(); + assertThatCode(() -> document.insertString(document.getLength(), "", null)) + .doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo(SCRIPT); + assertThat(intervalsOf(document)).isEqualTo(before); + assertStillConsistent(document); + } + + @Test + @DisplayName("inserting a null string is a no-op") + void insertNullString() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + + assertThatCode(() -> document.insertString(0, null, null)).doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo(SCRIPT); + } + + @Test + @DisplayName("removing zero characters is a no-op, at either end") + void removeNothing() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + List before = intervalsOf(document); + + assertThatCode(() -> document.remove(0, 0)).doesNotThrowAnyException(); + assertThatCode(() -> document.remove(document.getLength(), 0)).doesNotThrowAnyException(); + assertThatCode(() -> document.remove(document.getLength() / 2, 0)).doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo(SCRIPT); + assertThat(intervalsOf(document)).isEqualTo(before); + assertStillConsistent(document); + } + + @Test + @DisplayName("removing the first character keeps the text and the ranges consistent") + void removeFirstCharacter() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + + assertThatCode(() -> document.remove(0, 1)).doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo(SCRIPT.substring(1)); + assertStillConsistent(document); + } + + @Test + @DisplayName("removing the last character keeps the text and the ranges consistent") + void removeLastCharacter() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + + assertThatCode(() -> document.remove(document.getLength() - 1, 1)).doesNotThrowAnyException(); + + assertThat(textOf(document)).isEqualTo(SCRIPT.substring(0, SCRIPT.length() - 1)); + assertStillConsistent(document); + } + + @Test + @DisplayName("removing the entire document leaves an empty document with no ranges") + void removeEverything() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + + assertThatCode(() -> document.remove(0, document.getLength())).doesNotThrowAnyException(); + + assertThat(document.getLength()).isZero(); + assertThat(textOf(document)).isEmpty(); + assertStillConsistent(document); + assertThat(intervalsOf(document)).isEmpty(); + } + + @Test + @DisplayName("a long sequence of boundary edits never throws and never corrupts the text") + void manyBoundaryEditsInSequence() throws BadLocationException + { + ScriptDocument document = documentContaining(SCRIPT); + StringBuilder expected = new StringBuilder(SCRIPT); + + for (int i = 0; i < 40; i++) + { + document.insertString(0, "A", null); + expected.insert(0, "A"); + document.insertString(document.getLength(), "B\n", null); + expected.append("B\n"); + int middle = document.getLength() / 2; + document.insertString(middle, "C", null); + expected.insert(middle, "C"); + document.remove(0, 1); + expected.deleteCharAt(0); + document.remove(document.getLength() - 1, 1); + expected.deleteCharAt(expected.length() - 1); + + document.setSyntaxAttributes(); + assertThat(textOf(document)).isEqualTo(expected.toString()); + assertRangesLieInsideDocument(document); + assertRangesDoNotCross(document); + } + } + + @Test + @DisplayName("after text is deleted, no range still points past the end of the document") + void rangesNeverOutliveTheTextTheyDescribe() throws BadLocationException + { + // Between an edit and the debounced re-highlight, ScriptPane still answers hovers and + // ctrl-clicks from this range set: it feeds getMin()/getMaxExclusive() straight into + // setCharacterAttributes and into String.substring on the current text. A range left + // pointing at offsets the document no longer has is a crash in that window, not a + // cosmetic staleness. + ScriptDocument document = documentContaining(SCRIPT); + assertThat(intervalsOf(document)).isNotEmpty(); + + document.remove(0, document.getLength() - 2); + + assertRangesLieInsideDocument(document); + } + + @Test + @DisplayName("after text is deleted, an element found by lookup can still be read as text") + void lookupAfterDeletionYieldsAUsableElement() throws BadLocationException + { + // This is the concrete failure the previous property guards against, spelled out: the + // element the pane would act on for a click at offset 0. + ScriptDocument document = documentContaining(SCRIPT); + document.remove(0, document.getLength() - 2); + + ScriptDocument.ElementRange found = document.getScriptElementList().find(0); + if (found == null) + return; // nothing to act on is a perfectly correct answer + + String text = textOf(document); + assertThat(found.getMaxExclusive()) + .as("lookup returned %s for a %d character document; ScriptPane would call " + + "text.substring(%d, %d) on it", + Interval.of(found), text.length(), found.getMin(), found.getMaxExclusive()) + .isLessThanOrEqualTo(text.length()); + } + + @Test + @DisplayName("an edit eventually causes the document to re-highlight on its own") + void editsTriggerADebouncedRehighlight() throws Exception + { + // The debounce exists so the file is not re-lexed twice per keystroke on the EDT. It still + // has to actually fire, or the colours simply stop tracking the text. + Fixture fixture = newLiveFixture(); + ScriptDocument document = fixture.document(); + + document.insertString(0, SCRIPT, null); + + long deadline = System.currentTimeMillis() + 15_000; + while (System.currentTimeMillis() < deadline && intervalsOf(document).isEmpty()) + Thread.sleep(20); + + assertThat(intervalsOf(document)) + .as("the debounced highlighting pass never ran") + .isNotEmpty(); + assertRangesLieInsideDocument(document); + assertRangesDoNotCross(document); + } + + @Test + @DisplayName("the debounced pass also brings the line number gutter back in step") + void debouncedPassUpdatesTheGutter() throws Exception + { + Fixture fixture = newLiveFixture(); + ScriptDocument document = fixture.document(); + String text = "End\nEnd\nEnd\n"; + + document.insertString(0, text, null); + + int expected = lineCountOf(text); + long deadline = System.currentTimeMillis() + 15_000; + while (System.currentTimeMillis() < deadline && gutterEntries(fixture.gutter()).size() != expected) + Thread.sleep(20); + + assertThat(gutterEntries(fixture.gutter())).hasSize(expected); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentHighlightingTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentHighlightingTest.java new file mode 100644 index 0000000..f6fb106 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentHighlightingTest.java @@ -0,0 +1,313 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.Interval; +import io.github.turtleisaac.variabletracker.ScriptVariable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.swing.text.BadLocationException; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; + +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +/** + * Properties of the range set a highlighting pass produces. + * + *

The range set is consumed by {@link ScriptPane} on every mouse move and every ctrl-click, and + * it is fed straight back into {@code setCharacterAttributes} and {@code String.substring}. So the + * things asserted here - ranges lie inside the document, ranges do not fight over a character, + * running the highlighter again changes nothing - are the preconditions of not crashing while the + * user types.

+ */ +class ScriptDocumentHighlightingTest +{ + /** Fixtures chosen to exercise different visitor branches, not to be valid Pokemon scripts. */ + static Stream scriptFixtures() + { + return Stream.of( + "", + "\n", + "End\n", + "End", + "WaitTime 5 0\nEnd\n", + "_0001:\n\tEnd\n", + "_0001:\n\tEnd\n_0001:\n\tEnd\n", + "script(1):\n\tEnd\n", + "script(0):\n\tEnd\n", + "script():\n\tEnd\n", + "Bogus 1\nEnd\n", + "End 1 2 3\n", + "WaitTime\n", + "\t\t\n \n", + "endTable\n", + "Overworld(1)\nEnd\n" + ); + } + + private static ScriptDocument highlighted(String text) + { + ScriptDocument document = newQuiescedDocument(); + try + { + document.insertString(0, text, null); + document.setSyntaxAttributes(); + } + catch (BadLocationException e) + { + throw new AssertionError("highlighting a freshly inserted document must be legal", e); + } + return document; + } + + static void assertRangesLieInsideDocument(ScriptDocument document) + { + int length = document.getLength(); + for (Interval interval : intervalsOf(document)) + { + assertThat(interval.min()) + .as("%s starts before the document", interval) + .isGreaterThanOrEqualTo(0); + assertThat(interval.min()) + .as("%s is empty or inverted", interval) + .isLessThan(interval.maxExclusive()); + assertThat(interval.maxExclusive()) + .as("%s ends past the end of a %d character document", interval, length) + .isLessThanOrEqualTo(length); + } + } + + static void assertRangesDoNotCross(ScriptDocument document) + { + List intervals = intervalsOf(document); + for (int i = 0; i < intervals.size(); i++) + { + for (int j = i + 1; j < intervals.size(); j++) + { + Interval a = intervals.get(i); + Interval b = intervals.get(j); + assertThat(a.disjointFrom(b) || a.encloses(b) || b.encloses(a)) + .as("%s and %s overlap without one containing the other, so the characters " + + "they share belong to two elements at once", a, b) + .isTrue(); + } + } + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("scriptFixtures") + @DisplayName("every range produced lies inside the document") + void rangesLieInsideTheDocument(String text) + { + // A range that ends past getLength() is a BadLocationException the next time anything + // styles it or slices the text with it. + assertRangesLieInsideDocument(highlighted(text)); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("scriptFixtures") + @DisplayName("no two ranges partially overlap: they are disjoint or one encloses the other") + void rangesDoNotPartiallyOverlap(String text) + { + // Nesting is deliberate - a parameter's tooltip sits inside its command's - and the lookup + // resolves it by returning the innermost. Ranges that merely cross have no such resolution: + // whichever style was written last silently wins on the shared characters. + assertRangesDoNotCross(highlighted(text)); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("scriptFixtures") + @DisplayName("the element under an offset is the smallest element covering it") + void lookupReturnsTheSmallestCoveringRange(String text) + { + ScriptDocument document = highlighted(text); + List intervals = intervalsOf(document); + + for (int offset = 0; offset < document.getLength(); offset++) + { + ScriptDocument.ElementRange found = document.getScriptElementList().find(offset); + int smallest = Integer.MAX_VALUE; + for (Interval interval : intervals) + { + if (offset >= interval.min() && offset < interval.maxExclusive()) + smallest = Math.min(smallest, interval.length()); + } + + if (smallest == Integer.MAX_VALUE) + { + assertThat(found).as("no range covers offset %d", offset).isNull(); + } + else + { + assertThat(found).as("some range covers offset %d", offset).isNotNull(); + assertThat(found.getLength()) + .as("offset %d is covered by a %d character element but lookup returned a " + + "%d character one", offset, smallest, found.getLength()) + .isEqualTo(smallest); + } + } + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("scriptFixtures") + @DisplayName("highlighting an unchanged document twice produces the same ranges") + void highlightingIsIdempotent(String text) throws BadLocationException + { + // A highlighter whose output depends on how many times it has run is carrying state across + // passes, and the pass count is driven by the user's typing speed. + ScriptDocument document = highlighted(text); + List first = intervalsOf(document); + + document.setSyntaxAttributes(); + List second = intervalsOf(document); + + document.setSyntaxAttributes(); + List third = intervalsOf(document); + + assertThat(second).isEqualTo(first); + assertThat(third).isEqualTo(first); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("scriptFixtures") + @DisplayName("highlighting leaves the document's characters untouched") + void highlightingDoesNotChangeTheText(String text) throws BadLocationException + { + ScriptDocument document = newQuiescedDocument(); + document.insertString(0, text, null); + + document.setSyntaxAttributes(); + assertThat(textOf(document)).isEqualTo(text); + + document.setSyntaxAttributes(); + assertThat(textOf(document)).isEqualTo(text); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("scriptFixtures") + @DisplayName("querying a document changes neither its text nor its ranges") + void queryingDoesNotMutate(String text) + { + // Command/query separation: reading a tooltip while the mouse moves must not be able to + // disturb what a later highlighting pass sees. + ScriptDocument document = highlighted(text); + String textBefore = textOf(document); + List rangesBefore = intervalsOf(document); + + for (int offset = -1; offset <= document.getLength() + 1; offset++) + document.getScriptElementList().find(offset); + document.getStyle("command"); + document.getStyle("label"); + document.getScriptElementList(); + textOf(document); + + assertThat(textOf(document)).isEqualTo(textBefore); + assertThat(intervalsOf(document)).isEqualTo(rangesBefore); + } + + @Test + @DisplayName("a variable parameter nests inside its command instead of crossing it") + void variableParameterNestsInsideItsCommand() throws BadLocationException + { + ScriptDocument document = newQuiescedDocument(); + document.setVariableList(List.of(new ScriptVariable("MyVar", 0x4000))); + document.insertString(0, "WaitTime MyVar 0\nEnd\n", null); + document.setSyntaxAttributes(); + + assertRangesLieInsideDocument(document); + assertRangesDoNotCross(document); + + // The narrower parameter element must be what a hover at its offsets resolves to. + ScriptDocument.ElementRange atParameter = document.getScriptElementList().find(10); + assertThat(atParameter).isNotNull(); + assertThat(atParameter.getLength()).isEqualTo("MyVar".length()); + } + + @Test + @DisplayName("text is preserved exactly, including non-ASCII and tokenizer-significant characters") + void textIsPreservedExactly() throws BadLocationException + { + String text = "End\n\t'quoted' \"double\" {braces} \\backslash\\ ; comment\n" + + "é中😀 café\n:()@#$%^&*\n"; + + ScriptDocument document = newQuiescedDocument(); + document.insertString(0, text, null); + + assertThat(document.getLength()).isEqualTo(text.length()); + assertThat(textOf(document)).isEqualTo(text); + + document.setSyntaxAttributes(); + + assertThat(textOf(document)).isEqualTo(text); + assertRangesLieInsideDocument(document); + assertRangesDoNotCross(document); + } + + @Test + @DisplayName("text inserted piece by piece is preserved exactly") + void textAccumulatedPieceByPieceIsPreserved() throws BadLocationException + { + ScriptDocument document = newQuiescedDocument(); + StringBuilder expected = new StringBuilder(); + + for (String piece : new String[]{"End", "\n", "\tWaitTime", " 5", " 0", "\n", "café\n"}) + { + document.insertString(document.getLength(), piece, null); + expected.append(piece); + document.setSyntaxAttributes(); + assertThat(textOf(document)).isEqualTo(expected.toString()); + } + } + + // --------------------------------------------------------------------------------------- + // Tokenizer termination. A tokenizer that fails to advance does not fail loudly - it spins + // on the EDT and the whole application stops repainting, which is the worst failure mode a + // text editor has. + // --------------------------------------------------------------------------------------- + + static Stream adversarialInputs() + { + StringBuilder tenThousandLines = new StringBuilder(); + for (int i = 0; i < 10_000; i++) + tenThousandLines.append("WaitTime 5 0\n"); + + return Stream.of( + Arguments.of("one very long token", "A".repeat(50_000)), + Arguments.of("one very long number", "1".repeat(50_000)), + Arguments.of("unterminated quote", "End 'abc\nEnd\n"), + Arguments.of("only an opening quote", "'"), + Arguments.of("unbalanced braces", "{".repeat(5_000)), + Arguments.of("mismatched braces", "}{}{}}{".repeat(1_000)), + Arguments.of("unbalanced parentheses", "script(".repeat(2_000)), + Arguments.of("two unterminated script headers", "script(script("), + Arguments.of("only whitespace", " \t\n".repeat(5_000)), + Arguments.of("a lone backslash", "\\"), + Arguments.of("many backslashes", "\\".repeat(5_000)), + Arguments.of("characters with no rule", "@#$%^&~`|".repeat(3_000)), + Arguments.of("a lone colon", ":"), + Arguments.of("nothing but colons", ":".repeat(5_000)), + Arguments.of("a NUL character", "\u0000End\n"), + Arguments.of("ten thousand lines", tenThousandLines.toString()) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("adversarialInputs") + @DisplayName("highlighting terminates and does not throw on adversarial input") + void highlightingTerminates(String description, String text) + { + ScriptDocument document = assertTimeoutPreemptively(Duration.ofSeconds(30), + () -> highlighted(text), + () -> "highlighting did not finish for " + description); + + assertThat(textOf(document)).isEqualTo(text); + assertRangesLieInsideDocument(document); + assertRangesDoNotCross(document); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentLineNumberTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentLineNumberTest.java new file mode 100644 index 0000000..3024853 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptDocumentLineNumberTest.java @@ -0,0 +1,180 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.Fixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.swing.text.BadLocationException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The gutter's contract is small and total: it shows one entry per line of the document, the first + * of which is 1. It used to start at 0 and to be filled with a hardcoded 2000 entries, so a short + * file was numbered past its own end and a long one ran out of numbers. + */ +class ScriptDocumentLineNumberTest +{ + /** Rebuilds the gutter for the document's current text through the public entry point. */ + private static List gutterFor(String text) + { + Fixture fixture = newQuiescedFixture(); + try + { + fixture.document().insertString(0, text, null); + } + catch (BadLocationException e) + { + throw new AssertionError(e); + } + fixture.document().setLineNumberPane(fixture.gutter()); + return gutterEntries(fixture.gutter()); + } + + static Stream documents() + { + return Stream.of( + "", + "End", + "End\n", + "End\nEnd", + "End\nEnd\n", + "\n", + "\n\n\n", + "a\nb\nc\nd\ne" + ); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("documents") + @DisplayName("there is exactly one gutter entry per line of the document") + void oneEntryPerLine(String text) + { + // Lines are counted independently of Swing here: no newline is one line, and each newline + // opens another (a trailing newline opens a final empty line, which an editor still shows). + assertThat(gutterFor(text)).hasSize(lineCountOf(text)); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("documents") + @DisplayName("gutter entries are the consecutive integers 1..lineCount") + void entriesAreOneBasedAndConsecutive(String text) + { + List expected = new ArrayList<>(); + for (int line = 1; line <= lineCountOf(text); line++) + expected.add(String.valueOf(line)); + + assertThat(gutterFor(text)).isEqualTo(expected); + } + + @Test + @DisplayName("the first line of a document is line 1, never line 0") + void firstLineIsOne() + { + // The whole point of a gutter is that the number beside a line is the number the user can + // quote to someone else. Zero-based numbering makes every one of those references wrong. + assertThat(gutterFor("")).first().isEqualTo("1"); + assertThat(gutterFor("End\n")).first().isEqualTo("1"); + assertThat(gutterFor("a\nb\nc\n")).first().isEqualTo("1"); + } + + @Test + @DisplayName("an empty document still shows a single line numbered 1") + void emptyDocumentHasOneLine() + { + assertThat(gutterFor("")).containsExactly("1"); + } + + @Test + @DisplayName("a one line document shows exactly one number") + void oneLineDocument() + { + assertThat(gutterFor("WaitTime 5 0")).containsExactly("1"); + } + + @Test + @DisplayName("a two line document shows exactly two numbers") + void twoLineDocument() + { + assertThat(gutterFor("WaitTime 5 0\nEnd")).containsExactly("1", "2"); + } + + @Test + @DisplayName("the gutter is not truncated for documents of several thousand lines") + void severalThousandLines() + { + // The old gutter stopped at a hardcoded 2000 entries, so everything below line 2000 in a + // real script file was unnumbered. + int lines = 5_000; + String text = "WaitTime 5 0\n".repeat(lines - 1) + "End"; + + List entries = gutterFor(text); + + assertThat(entries).hasSize(lines); + assertThat(entries.get(0)).isEqualTo("1"); + assertThat(entries.get(1_999)).isEqualTo("2000"); + assertThat(entries.get(2_000)).isEqualTo("2001"); + assertThat(entries.get(lines - 1)).isEqualTo(String.valueOf(lines)); + } + + @Test + @DisplayName("the gutter never shows more numbers than the document has lines") + void gutterIsNotPaddedBeyondTheDocument() + { + // Padding a short file out to a fixed length is what the hardcoded 2000 did. + assertThat(gutterFor("End\n")).hasSize(2).doesNotContain("3", "2000"); + } + + @Test + @DisplayName("the gutter tracks the document as lines are added and removed") + void gutterFollowsEdits() throws BadLocationException + { + Fixture fixture = newQuiescedFixture(); + ScriptDocument document = fixture.document(); + + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 30; i++) + { + text.append("End\n"); + document.insertString(document.getLength(), "End\n", null); + document.setLineNumberPane(fixture.gutter()); + assertThat(gutterEntries(fixture.gutter())) + .as("after appending line %d", i + 1) + .hasSize(lineCountOf(text.toString())); + } + + while (document.getLength() > 0) + { + document.remove(document.getLength() - 1, 1); + text.deleteCharAt(text.length() - 1); + document.setLineNumberPane(fixture.gutter()); + assertThat(gutterEntries(fixture.gutter())) + .as("after deleting back to %d characters", document.getLength()) + .hasSize(lineCountOf(text.toString())); + } + + assertThat(gutterEntries(fixture.gutter())).containsExactly("1"); + } + + @Test + @DisplayName("a document with no gutter attached highlights and edits without complaint") + void noGutterAttached() throws BadLocationException + { + // ScriptPane.getLineNumberPane() is null until a gutter is installed, and the document is + // constructed from it, so the null case is on the normal startup path. + ScriptPane pane = new ScriptPane(); + ScriptDocument document = new ScriptDocument(pane); + disarmDebounce(document); + + document.insertString(0, "End\nEnd\n", null); + document.setSyntaxAttributes(); + + assertThat(textOf(document)).isEqualTo("End\nEnd\n"); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptElementListTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptElementListTest.java new file mode 100644 index 0000000..a87dd5c --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptElementListTest.java @@ -0,0 +1,171 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.newQuiescedDocument; +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.intervalsOf; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link ScriptDocument.ScriptElementList} is the lookup structure behind every tooltip and every + * ctrl-click: the pane asks it which element sits under a caret offset. Its whole job is to answer + * that question, so the properties worth pinning down are "the answer contains the offset asked + * about" and "the answer is the most specific element there". + */ +class ScriptElementListTest +{ + private ScriptDocument document; + private ScriptDocument.ScriptElementList list; + + @BeforeEach + void setUp() + { + document = newQuiescedDocument(); + list = new ScriptDocument.ScriptElementList(); + } + + private ScriptDocument.ElementRange range(int min, int maxExclusive) + { + return document.new ElementRange(min, maxExclusive, min + ".." + maxExclusive); + } + + @Test + @DisplayName("an empty list finds nothing") + void emptyListFindsNothing() + { + assertThat(list.find(0)).isNull(); + assertThat(list.find(17)).isNull(); + } + + @Test + @DisplayName("find returns null for an offset no range covers") + void findsNothingOutsideEveryRange() + { + list.add(range(5, 10)); + list.add(range(20, 25)); + + assertThat(list.find(4)).as("before the first range").isNull(); + assertThat(list.find(10)).as("one past the end of the first range").isNull(); + assertThat(list.find(15)).as("in the gap").isNull(); + assertThat(list.find(25)).as("one past the end of the last range").isNull(); + } + + @Test + @DisplayName("find never returns a range that does not contain the offset asked about") + void findReturnsOnlyContainingRanges() + { + list.add(range(0, 4)); + list.add(range(4, 9)); + list.add(range(9, 30)); + list.add(range(12, 15)); + + for (int offset = -2; offset < 35; offset++) + { + ScriptDocument.ElementRange found = list.find(offset); + if (found != null) + { + assertThat(found.contains(offset)) + .as("find(%d) returned [%d,%d)", offset, found.getMin(), found.getMaxExclusive()) + .isTrue(); + } + } + } + + @Test + @DisplayName("every offset covered by some range is found") + void everyCoveredOffsetIsFound() + { + list.add(range(3, 7)); + list.add(range(11, 12)); + + for (int offset : new int[]{3, 4, 5, 6, 11}) + { + assertThat(list.find(offset)).as("offset %d is covered", offset).isNotNull(); + } + } + + @Test + @DisplayName("when ranges nest, find returns the innermost one") + void findReturnsTheInnermostRange() + { + // A command's tooltip covers the whole line; a variable parameter inside it has its own, + // more specific tooltip. Hovering the parameter must show the parameter's tooltip, not the + // command's, otherwise the more specific information is unreachable. + ScriptDocument.ElementRange command = range(0, 17); + ScriptDocument.ElementRange parameter = range(9, 14); + list.add(command); + list.add(parameter); + + assertThat(list.find(9)).isSameAs(parameter); + assertThat(list.find(13)).isSameAs(parameter); + assertThat(list.find(0)).isSameAs(command); + assertThat(list.find(8)).isSameAs(command); + assertThat(list.find(14)).isSameAs(command); + } + + @Test + @DisplayName("the innermost range wins regardless of the order the ranges were added in") + void innermostWinsWhicheverOrderRangesArrive() + { + ScriptDocument.ElementRange command = range(0, 17); + ScriptDocument.ElementRange parameter = range(9, 14); + list.add(parameter); + list.add(command); + + assertThat(list.find(11)).isSameAs(parameter); + } + + @Test + @DisplayName("adding one range stores exactly one range") + void addStoresExactlyOneRange() + { + // Storing a range once per enclosing range makes the list grow with nesting depth and + // makes the same element answer twice, which is a leak in a structure rebuilt on a timer. + list.add(range(0, 20)); + assertThat(intervalsOf(list)).as("after one add").hasSize(1); + + list.add(range(0, 10)); + assertThat(intervalsOf(list)).as("after two adds").hasSize(2); + + list.add(range(2, 5)); + assertThat(intervalsOf(list)).as("after three adds").hasSize(3); + } + + @Test + @DisplayName("no range is stored twice") + void noDuplicateRanges() + { + list.add(range(0, 20)); + list.add(range(0, 10)); + list.add(range(2, 5)); + + assertThat(intervalsOf(list)).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("disjoint ranges are stored one for one") + void disjointRangesAreStoredOneForOne() + { + list.add(range(0, 4)); + list.add(range(4, 9)); + list.add(range(9, 13)); + + assertThat(intervalsOf(list)).hasSize(3); + } + + @Test + @DisplayName("clear empties the list so nothing can be found afterwards") + void clearRemovesEverything() + { + list.add(range(0, 4)); + list.add(range(4, 9)); + + list.clear(); + + assertThat(intervalsOf(list)).isEmpty(); + assertThat(list.find(0)).isNull(); + assertThat(list.find(5)).isNull(); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPaneTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPaneTest.java new file mode 100644 index 0000000..5ef41a0 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptPaneTest.java @@ -0,0 +1,198 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.Fixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.JTextPane; +import javax.swing.text.BadLocationException; +import javax.swing.text.StyleConstants; + +import static io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts.ScriptTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * The pane's own arithmetic: which line an offset is on, and which characters get restyled when a + * label is followed. Both are places a +1 has historically crept in - the pane used to style + * {@code max - min + 1} characters, painting one character beyond every label it highlighted. + */ +class ScriptPaneTest +{ + private static final String SCRIPT = "WaitTime 5 0\nEnd\nGoTo _0001\nEnd\n"; + + private Fixture wiredFixture() throws BadLocationException + { + Fixture fixture = newQuiescedFixture(); + fixture.pane().setScriptDocument(fixture.document()); + fixture.document().insertString(0, SCRIPT, null); + fixture.document().setSyntaxAttributes(); + return fixture; + } + + @Test + @DisplayName("the first line of a document is line 1, never line 0") + void lineNumbersAreOneBased() + { + JTextPane pane = new JTextPane(); + pane.setText("alpha\nbeta\ngamma"); + + assertThat(ScriptPane.getLineAtOffset(pane, 0)).isEqualTo(1); + assertThat(ScriptPane.getLineAtOffset(pane, 4)).isEqualTo(1); + assertThat(ScriptPane.getLineAtOffset(pane, 6)).isEqualTo(2); + assertThat(ScriptPane.getLineAtOffset(pane, 11)).isEqualTo(3); + } + + @Test + @DisplayName("the line reported for an offset is the line the newlines before it put it on") + void lineNumberAgreesWithTheNewlinesBeforeTheOffset() + { + String text = "a\nbb\n\nccc\ndddd"; + JTextPane pane = new JTextPane(); + pane.setText(text); + + for (int offset = 0; offset < text.length(); offset++) + { + int newlinesBefore = 0; + for (int i = 0; i < offset; i++) + { + if (text.charAt(i) == '\n') + newlinesBefore++; + } + + assertThat(ScriptPane.getLineAtOffset(pane, offset)) + .as("offset %d follows %d newlines", offset, newlinesBefore) + .isEqualTo(newlinesBefore + 1); + } + } + + @Test + @DisplayName("going to line n leaves the caret on line n") + void gotoStartOfLineIsTheInverseOfGetLineAtOffset() + { + JTextPane pane = new JTextPane(); + pane.setText("alpha\nbeta\ngamma\ndelta"); + + for (int line = 1; line <= 4; line++) + { + ScriptPane.gotoStartOfLine(pane, line); + assertThat(ScriptPane.getLineAtCaret(pane)).as("after going to line %d", line).isEqualTo(line); + assertThat(ScriptPane.getLineAtOffset(pane, pane.getCaretPosition())).isEqualTo(line); + } + } + + @Test + @DisplayName("going to line 1 puts the caret at offset 0") + void gotoFirstLine() + { + JTextPane pane = new JTextPane(); + pane.setText("alpha\nbeta"); + + ScriptPane.gotoStartOfLine(pane, 1); + + assertThat(pane.getCaretPosition()).isZero(); + } + + @Test + @DisplayName("going to a line outside the document clamps to a real line instead of throwing") + void gotoLineOutsideTheDocumentIsClamped() + { + JTextPane pane = new JTextPane(); + pane.setText("alpha\nbeta\ngamma"); + + assertThatCode(() -> ScriptPane.gotoStartOfLine(pane, 0)).doesNotThrowAnyException(); + assertThat(ScriptPane.getLineAtCaret(pane)).isEqualTo(1); + + assertThatCode(() -> ScriptPane.gotoStartOfLine(pane, 9_999)).doesNotThrowAnyException(); + assertThat(ScriptPane.getLineAtCaret(pane)).isEqualTo(3); + + assertThatCode(() -> ScriptPane.gotoStartOfLine(pane, -5)).doesNotThrowAnyException(); + assertThat(ScriptPane.getLineAtCaret(pane)).isEqualTo(1); + } + + @Test + @DisplayName("following a label restyles exactly the label's own characters") + void highlightStylesExactlyTheRangeAndNoMore() throws BadLocationException + { + // The label element covers "_0001" in "GoTo _0001". Styling min..maxExclusive-1 is the + // whole range; styling one more would bleed the goto styling onto the following newline, + // and styling one fewer would leave the label's last character behind. + Fixture fixture = wiredFixture(); + ScriptDocument document = fixture.document(); + + int min = SCRIPT.indexOf("_0001"); + int maxExclusive = min + "_0001".length(); + document.getScriptElementList().add( + document.new ElementRange(min, maxExclusive, "label", ScriptDocument.ElementType.LABEL)); + + paneHighlight(fixture.pane(), min + 1); + + for (int offset = min; offset < maxExclusive; offset++) + { + assertThat(StyleConstants.isUnderline(document.getCharacterElement(offset).getAttributes())) + .as("character %d ('%s') is inside the label and must be styled", + offset, SCRIPT.charAt(offset)) + .isTrue(); + } + + assertThat(StyleConstants.isUnderline(document.getCharacterElement(min - 1).getAttributes())) + .as("the character before the label must not be styled") + .isFalse(); + assertThat(StyleConstants.isUnderline(document.getCharacterElement(maxExclusive).getAttributes())) + .as("the character after the label must not be styled") + .isFalse(); + } + + @Test + @DisplayName("following a label leaves the document's characters untouched") + void highlightDoesNotChangeTheText() throws BadLocationException + { + Fixture fixture = wiredFixture(); + ScriptDocument document = fixture.document(); + + int min = SCRIPT.indexOf("_0001"); + document.getScriptElementList().add( + document.new ElementRange(min, min + 5, "label", ScriptDocument.ElementType.LABEL)); + + paneHighlight(fixture.pane(), min); + + assertThat(textOf(document)).isEqualTo(SCRIPT); + } + + @Test + @DisplayName("a pane with no script document answers tooltip queries with null instead of throwing") + void tooltipWithoutADocument() + { + ScriptPane pane = new ScriptPane(); + + assertThat(pane.getScriptDocument()).isNull(); + assertThatCode(() -> paneHighlight(pane, 0)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("installing a gutter empties it and hands it back") + void installingAGutter() throws BadLocationException + { + ScriptPane pane = new ScriptPane(); + JTextPane gutter = new JTextPane(); + gutter.setText("stale\ncontent\n"); + + pane.setLineNumberPane(gutter); + + assertThat(pane.getLineNumberPane()).isSameAs(gutter); + assertThat(gutter.getDocument().getLength()).isZero(); + assertThat(gutter.isEditable()).isFalse(); + } + + @Test + @DisplayName("the pane reports back the script document installed on it") + void installingAScriptDocument() + { + Fixture fixture = newQuiescedFixture(); + + fixture.pane().setScriptDocument(fixture.document()); + + assertThat(fixture.pane().getScriptDocument()).isSameAs(fixture.document()); + assertThat(fixture.pane().getStyledDocument()).isSameAs(fixture.document()); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptTestSupport.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptTestSupport.java new file mode 100644 index 0000000..e32de97 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/editors/data/formats/scripts/ScriptTestSupport.java @@ -0,0 +1,224 @@ +package io.github.turtleisaac.pokeditor.gui.editors.data.formats.scripts; + +import javax.swing.JTextPane; +import javax.swing.Timer; +import javax.swing.text.BadLocationException; +import java.awt.event.ActionListener; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * Shared fixtures for the script editor tests. + * + *

Two seams are needed and neither exists as public API, so both are reached by reflection + * rather than by widening visibility in src/main:

+ *
    + *
  • {@code ScriptDocument.syntaxTimer} - the 250ms debounce that re-highlights after an + * edit. It fires on the EDT, so unless it is neutered every assertion about the range + * set races a background re-lex of the same document.
  • + *
  • {@code ScriptElementList.elementRanges} - the range set itself. The only public + * accessor is {@code find(int)}, which cannot show duplicates, ordering or ranges that + * point outside the document.
  • + *
+ */ +final class ScriptTestSupport +{ + static + { + // Swing is only constructible in this environment when AWT is told there is no display. + System.setProperty("java.awt.headless", "true"); + } + + private ScriptTestSupport() {} + + /** A document plus the panes it is wired to, so tests can reach the line-number gutter. */ + record Fixture(ScriptPane pane, JTextPane gutter, ScriptDocument document) {} + + /** + * Builds a document whose debounce timer has been disarmed. Every test that asserts something + * about the range set must use this: the timer would otherwise re-run the visitor on the EDT + * concurrently with the test thread, and the two runs share one un-synchronized ArrayList. + */ + static Fixture newQuiescedFixture() + { + Fixture fixture = newLiveFixture(); + disarmDebounce(fixture.document()); + return fixture; + } + + /** Builds a document with the real debounce timer still running. */ + static Fixture newLiveFixture() + { + try + { + ScriptPane pane = new ScriptPane(); + JTextPane gutter = new JTextPane(); + pane.setLineNumberPane(gutter); + ScriptDocument document = new ScriptDocument(pane); + return new Fixture(pane, gutter, document); + } + catch (BadLocationException e) + { + throw new AssertionError("failed to build a script document fixture", e); + } + } + + static ScriptDocument newQuiescedDocument() + { + return newQuiescedFixture().document(); + } + + static void disarmDebounce(ScriptDocument document) + { + try + { + Field field = ScriptDocument.class.getDeclaredField("syntaxTimer"); + field.setAccessible(true); + Timer timer = (Timer) field.get(document); + timer.stop(); + for (ActionListener listener : timer.getActionListeners()) + timer.removeActionListener(listener); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("could not disarm the syntax debounce timer", e); + } + } + + /** The document's whole text, which is what every offset in a range is an index into. */ + static String textOf(ScriptDocument document) + { + try + { + return document.getText(0, document.getLength()); + } + catch (BadLocationException e) + { + throw new AssertionError("getText(0, getLength()) must always be legal", e); + } + } + + /** Snapshot of the range set. Reflection: {@code elementRanges} has no accessor. */ + @SuppressWarnings("unchecked") + static List rangesOf(ScriptDocument document) + { + return rangesOf(document.getScriptElementList()); + } + + @SuppressWarnings("unchecked") + static List rangesOf(ScriptDocument.ScriptElementList list) + { + try + { + Field field = ScriptDocument.ScriptElementList.class.getDeclaredField("elementRanges"); + field.setAccessible(true); + return new ArrayList<>((List) field.get(list)); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("could not read the element range set", e); + } + } + + /** Half-open interval, so failure messages read as intervals rather than as object ids. */ + record Interval(int min, int maxExclusive) + { + static Interval of(ScriptDocument.ElementRange range) + { + return new Interval(range.getMin(), range.getMaxExclusive()); + } + + boolean disjointFrom(Interval other) + { + return maxExclusive <= other.min || other.maxExclusive <= min; + } + + boolean encloses(Interval other) + { + return min <= other.min && other.maxExclusive <= maxExclusive; + } + + int length() + { + return maxExclusive - min; + } + + @Override + public String toString() + { + return "[" + min + "," + maxExclusive + ")"; + } + } + + static List intervalsOf(ScriptDocument document) + { + return toIntervals(rangesOf(document)); + } + + static List intervalsOf(ScriptDocument.ScriptElementList list) + { + return toIntervals(rangesOf(list)); + } + + private static List toIntervals(List ranges) + { + List intervals = new ArrayList<>(); + for (ScriptDocument.ElementRange range : ranges) + intervals.add(Interval.of(range)); + return intervals; + } + + /** Invokes {@code ScriptPane.highlight(int)}, which is private but is the styling path + * a ctrl-click takes. Driving it through a real MouseEvent would need a laid-out window. */ + static void paneHighlight(ScriptPane pane, int offset) + { + try + { + Method method = ScriptPane.class.getDeclaredMethod("highlight", int.class); + method.setAccessible(true); + method.invoke(pane, offset); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("could not invoke ScriptPane.highlight", e); + } + } + + /** The line numbers currently rendered in the gutter, in order. */ + static List gutterEntries(JTextPane gutter) + { + try + { + String text = gutter.getDocument().getText(0, gutter.getDocument().getLength()); + List entries = new ArrayList<>(); + for (String line : text.split("\n", -1)) + { + if (!line.isEmpty()) + entries.add(line); + } + return entries; + } + catch (BadLocationException e) + { + throw new AssertionError("could not read the line number gutter", e); + } + } + + /** + * The number of lines a text has, defined independently of Swing: a document with no newline + * is one line, and every newline starts one more (a trailing newline therefore opens a final + * empty line, which an editor still numbers). + */ + static int lineCountOf(String text) + { + int lines = 1; + for (int i = 0; i < text.length(); i++) + { + if (text.charAt(i) == '\n') + lines++; + } + return lines; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/NumberOnlyCellEditorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/NumberOnlyCellEditorTest.java new file mode 100644 index 0000000..4b5e983 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/NumberOnlyCellEditorTest.java @@ -0,0 +1,366 @@ +package io.github.turtleisaac.pokeditor.gui.sheets; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.NumberOnlyCellEditor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.JTextField; +import java.awt.HeadlessException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * A cell editor for a numeric column has two jobs, and they pull against each other: show the + * value that is already in the cell without altering it, and refuse a value the column cannot + * hold. Both have failed here in the same direction - quietly. A filter which stripped the minus + * sign mangled a negative value on the way in, before the user typed anything; a range check + * which could not fail let an out-of-range value through to be truncated to a byte on save. + *

+ * The column's legal range is the {min, max} pair the sheet's model publishes for that column + * (see {@code FormatModel.getCellValueRange}); move priority, written to the ROM as a signed + * byte, is {-128, 127}, and Trick Room's priority is -7. + */ +class NumberOnlyCellEditorTest +{ + private static final int SIGNED_MIN = -128; + private static final int SIGNED_MAX = 127; + + /** Seeds the editor as the table would when the user starts editing a cell. */ + private static JTextField seed(NumberOnlyCellEditor editor, Object cellValue) + { + return (JTextField) editor.getTableCellEditorComponent(null, cellValue, false, 0, 0); + } + + /** + * Whether the editor let the edit be committed. + *

+ * Rejection is reported to the user through a modal dialog, which cannot be shown in a + * headless JVM - so an attempt to open one is itself proof the value was refused. What + * matters either way is that a refused value never comes back as {@code true}. + */ + private static boolean commits(NumberOnlyCellEditor editor) + { + try { + return editor.stopCellEditing(); + } + catch (HeadlessException dialogWouldHaveBeenShown) { + return false; + } + } + + private static int readBack(NumberOnlyCellEditor editor) + { + return Integer.parseInt(String.valueOf(editor.getCellEditorValue()).trim()); + } + + /** + * Opening the editor on a cell must not change the cell. Every value the column admits has + * to survive the trip in and straight back out - the user has not typed anything yet. + */ + @Test + @DisplayName("every value an unsigned column admits comes back unchanged from the editor") + void seedingAnUnsignedColumnIsLossless() + { + for (int value = 0; value <= 255; value++) + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 255); + + seed(editor, value); + assertThat(readBack(editor)).as("Integer %d seeded into a 0..255 column", value).isEqualTo(value); + + seed(editor, String.valueOf(value)); + assertThat(readBack(editor)).as("String \"%d\" seeded into a 0..255 column", value).isEqualTo(value); + } + } + + /** + * The same for a signed column - and this is where the editor used to lose data before the + * user could touch it, because setting the field's text runs through the same filter as + * typing does, and the filter deleted the minus sign. + */ + @Test + @DisplayName("every value a signed column admits, negatives included, comes back unchanged from the editor") + void seedingASignedColumnIsLossless() + { + for (int value = SIGNED_MIN; value <= SIGNED_MAX; value++) + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(SIGNED_MIN, SIGNED_MAX); + + seed(editor, value); + assertThat(readBack(editor)).as("Integer %d seeded into a -128..127 column", value).isEqualTo(value); + + seed(editor, String.valueOf(value)); + assertThat(readBack(editor)).as("String \"%d\" seeded into a -128..127 column", value).isEqualTo(value); + } + } + + /** + * The concrete case: opening the priority cell of Trick Room and pressing escape must leave + * -7 in it, and the field must be showing -7 while it is open, not 7. + */ + @Test + @DisplayName("opening a signed cell on a negative value shows that negative value") + void negativeValueIsDisplayedAsNegative() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(SIGNED_MIN, SIGNED_MAX); + + JTextField field = seed(editor, "-7"); + + assertThat(field.getText()).as("what the user sees when the editor opens on -7").isEqualTo("-7"); + assertThat(readBack(editor)).isEqualTo(-7); + assertThat(commits(editor)).as("committing an untouched cell holding a legal value").isTrue(); + } + + /** + * The endpoints of the declared range are legal values, not near misses. An off-by-one that + * excluded them would make the largest legal stat or the most negative priority unenterable. + */ + @Test + @DisplayName("the values at the minimum and the maximum are accepted") + void boundsThemselvesAreAccepted() + { + for (int[] range : new int[][] {{0, 255}, {SIGNED_MIN, SIGNED_MAX}, {0, 65535}}) + { + for (int value : new int[] {range[0], range[1]}) + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(range[0], range[1]); + seed(editor, String.valueOf(value)); + + assertThat(commits(editor)) + .as("committing %d in a %d..%d column", value, range[0], range[1]) + .isTrue(); + assertThat(readBack(editor)).isEqualTo(value); + } + } + } + + /** + * One past either end is not a legal value, and the editor is the only thing standing between + * it and a write that truncates it into range. A range test which cannot be false - the + * classic {@code v >= min || v <= max} - passes everything, so this is stated at both ends, + * as the property that survives whichever defence catches it: the editor never commits a + * value outside the range it declares, and never reports one either. + */ + @Test + @DisplayName("a value one past the minimum or one past the maximum is never committed") + void valuesJustOutsideTheRangeAreRefused() + { + for (int[] range : new int[][] {{0, 255}, {SIGNED_MIN, SIGNED_MAX}, {0, 65535}}) + { + for (int value : new int[] {range[0] - 1, range[1] + 1}) + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(range[0], range[1]); + JTextField field = seed(editor, String.valueOf(range[0])); + field.setText(String.valueOf(value)); + + boolean committed = commits(editor); + int reported = readBack(editor); + + assertThat(committed && reported == value) + .as("committed %d in a %d..%d column", value, range[0], range[1]) + .isFalse(); + assertThat(reported) + .as("value reported by a %d..%d column after being handed %d", range[0], range[1], value) + .isBetween(range[0], range[1]); + } + } + } + + /** + * Refusing a value means the value does not get in - it does not mean quietly folding it into + * range. 300 in a 0..255 column must not become 44, and 200 in a signed byte column must not + * become -56, which is exactly what writing the value out as a byte would do. + */ + @Test + @DisplayName("an out-of-range value is refused rather than wrapped into range") + void outOfRangeValueIsNotTruncated() + { + NumberOnlyCellEditor unsigned = new NumberOnlyCellEditor(0, 255); + JTextField unsignedField = seed(unsigned, "10"); + unsignedField.setText("300"); + + assertThat(commits(unsigned)).as("committing 300 in a 0..255 column").isFalse(); + assertThat(readBack(unsigned)) + .as("the value the editor hands back after refusing 300 - 300 truncated to a byte is 44") + .isNotEqualTo(44) + .isEqualTo(10); + + NumberOnlyCellEditor signed = new NumberOnlyCellEditor(SIGNED_MIN, SIGNED_MAX); + JTextField signedField = seed(signed, "-7"); + signedField.setText("200"); + + assertThat(commits(signed)).as("committing 200 in a -128..127 column").isFalse(); + assertThat(readBack(signed)) + .as("the value the editor hands back after refusing 200 - 200 as a signed byte is -56") + .isNotEqualTo(-56) + .isEqualTo(-7); + } + + /** + * Allowing the minus sign on signed columns must not have handed it to every column. An + * unsigned column has no negative values, so one must never be able to reach the field - + * neither by being typed nor by arriving as the cell's own value. + */ + @Test + @DisplayName("an unsigned column never lets a minus sign into the field") + void unsignedColumnRejectsTheMinusSign() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 255); + + JTextField field = seed(editor, "-5"); + assertThat(field.getText()).as("an unsigned column seeded with -5").doesNotContain("-"); + + seed(editor, "12"); + field.setText("-12"); + assertThat(field.getText()).as("an unsigned column asked to hold -12").doesNotContain("-"); + + seed(editor, "12"); + field.setText("1-2"); + assertThat(field.getText()).as("a minus sign in the middle of a number").doesNotContain("-"); + } + + /** + * A signed column takes the minus sign only where a minus sign belongs. "1-2" is not a number + * in any column. + */ + @Test + @DisplayName("a signed column takes a leading minus sign but not one in the middle of a number") + void signedColumnTakesOnlyALeadingMinus() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(SIGNED_MIN, SIGNED_MAX); + + JTextField field = seed(editor, "0"); + field.setText("-42"); + assertThat(field.getText()).isEqualTo("-42"); + + field.setText("1-2"); + assertThat(field.getText()).as("a minus sign in the middle of a number").doesNotContain("-"); + } + + /** + * Letters are not numbers, and a cell left holding nothing is not zero. Either way the edit + * must be refused rather than committed as some arbitrary value. + */ + @Test + @DisplayName("a cell left empty or containing no digits is refused") + void nonNumericInputIsRefused() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 255); + + JTextField field = seed(editor, "7"); + field.setText(""); + assertThat(commits(editor)).as("committing an empty cell").isFalse(); + + seed(editor, "7"); + field.setText("abc"); + assertThat(commits(editor)).as("committing letters").isFalse(); + } + + /** + * The editor is reused across cells, so it must not carry the previous cell's text into a + * cell which has no value - that would silently write the old cell's number into the new one. + */ + @Test + @DisplayName("opening the editor on a blank cell does not leave the previous cell's text behind") + void blankCellDoesNotInheritThePreviousValue() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 255); + + seed(editor, "123"); + JTextField field = seed(editor, null); + + assertThat(field.getText()).isEmpty(); + } + + /** The default column is a single unsigned byte, and it enforces that range like any other. */ + @Test + @DisplayName("the default editor enforces the unsigned byte range it declares") + void defaultEditorEnforcesItsDeclaredRange() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(); + + assertThat(editor.getMinimum()).isEqualTo(NumberOnlyCellEditor.DEFAULT_MINIMUM); + assertThat(editor.getMaximum()).isEqualTo(NumberOnlyCellEditor.DEFAULT_MAXIMUM); + + JTextField field = seed(editor, "0"); + assertThat(commits(editor)).as("committing the minimum").isTrue(); + + field.setText("256"); + assertThat(commits(editor)).as("committing one past the maximum").isFalse(); + } + + /** + * A blank cell is not a malformed one. A Learnsets row is only as long as that species' + * learnset, so every column past its last entry reads back null - which is most of that + * sheet. Opening one and clicking away put a modal error in front of the user for having + * changed nothing. + *

+ * {@link #commits} deliberately cannot tell the two apart: it reports a dialog attempt as a + * refusal, and a refusal is what this used to be. So the dialog is asserted on directly - + * in a headless JVM an attempt to open one throws, which makes "no throw" the proof that + * none was attempted. + */ + @Test + @DisplayName("a blank cell left blank ends the edit quietly instead of raising an error") + void blankCellLeftBlankIsNotAnError() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 100); + seed(editor, null); + + assertThatCode(editor::stopCellEditing) + .as("opening a blank cell and typing nothing must not put a dialog in the user's way") + .doesNotThrowAnyException(); + + assertThat(editor.stopCellEditing()) + .as("and it must not commit either - writing here pads a Learnsets row up to the " + + "cell being set, so committing a blank cell injects junk entries") + .isFalse(); + } + + /** + * The fallback exists so that an edit which changed nothing gives the cell back unchanged. + * It used to store {@code String.valueOf(value)}, which turns a blank cell into the four + * letters "null" - so the thing handed back as the cell's contents was text the write path + * then rejected as not a number. + */ + @Test + @DisplayName("a blank cell reads back as blank, not as the text \"null\"") + void blankCellDoesNotReadBackAsTheWordNull() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 100); + seed(editor, null); + + assertThat(editor.getCellEditorValue()) + .as("a cell with nothing in it is still a cell with nothing in it") + .isNull(); + } + + /** + * The other side of the same change: leaving a cell alone is a no-op, but emptying one that + * had a value is a real attempt to store nothing in a numeric column, and still refused. + */ + @Test + @DisplayName("clearing a cell that had a value is still refused") + void clearingAFilledCellIsStillRefused() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 100); + JTextField field = seed(editor, 42); + field.setText(""); + + assertThat(commits(editor)).as("emptying a filled numeric cell").isFalse(); + } + + /** And a blank cell the user actually fills in commits normally. */ + @Test + @DisplayName("a blank cell the user fills in commits the typed value") + void blankCellFilledInCommits() + { + NumberOnlyCellEditor editor = new NumberOnlyCellEditor(0, 100); + JTextField field = seed(editor, null); + field.setText("30"); + + assertThat(commits(editor)).as("committing a value typed into a blank cell").isTrue(); + assertThat(readBack(editor)).isEqualTo(30); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/NumericPasteTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/NumericPasteTest.java new file mode 100644 index 0000000..08e6a12 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/NumericPasteTest.java @@ -0,0 +1,169 @@ +package io.github.turtleisaac.pokeditor.gui.sheets; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.DefaultTable; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.github.turtleisaac.pokeditor.formats.GenericFileData; + +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ActionEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pasting into a sheet whose columns actually convert and validate their input. + *

+ * Every other paste test in this package runs against a fixture whose columns are all + * {@code CellTypes.STRING}. That was not a deliberate simplification, it was a hole: + * {@code prepareObjectForWriting} does nothing at all for text, so those tests exercise the + * geometry and none of the conversion. A regression that refused every spreadsheet paste + * outright shipped straight through them. + *

+ * The properties here are about what a paste means, not about how it is implemented: + *

    + *
  • a block copied out of a spreadsheet lands, and the trailing newline every spreadsheet + * appends is a terminator rather than a row of data;
  • + *
  • a blank cell means "nothing here" and leaves the sheet's value alone, in a numeric + * column where no spelling of empty is a number;
  • + *
  • a paste is all or nothing - if any cell is refused, no cell is written, because the + * write path cannot roll back and there is no undo.
  • + *
+ */ +class NumericPasteTest +{ + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private static final int MIN = 0; + private static final int MAX = 255; + + /** Selects the whole grid and fires the paste action against the given clipboard text. */ + private static TestSheet.Model pasteInto(TestSheet.Model model, String clipboard) + { + TestSheet.Table table = new TestSheet.Table(model); + table.setRowSelectionInterval(0, table.getRowCount() - 1); + table.setColumnSelectionInterval(0, table.getColumnCount() - 1); + + // a clipboard of our own rather than the system one, which needs a display + Clipboard board = new Clipboard("test"); + board.setContents(new StringSelection(clipboard), null); + + DefaultTable.PasteAction action = + new DefaultTable.PasteAction<>(table) + { + @Override + protected Clipboard getClipboard() + { + return board; + } + }; + action.actionPerformed(new ActionEvent(table, ActionEvent.ACTION_PERFORMED, "Paste")); + return model; + } + + @Test + @DisplayName("a block copied out of a spreadsheet lands, trailing newline and all") + void spreadsheetBlockLands() + { + // Excel, LibreOffice and Sheets all terminate the final row with a newline. Splitting + // with a negative limit keeps the empty field after it, so a 2x2 copy arrives as three + // rows. Treating that as data is what refused the paste outright. + TestSheet.Model model = pasteInto(TestSheet.numericGrid(4, MIN, MAX), "1\t2\r\n3\t4\r\n"); + + assertThat(model.getValueAt(0, 0)).as("row 0 col 0").isEqualTo(1); + assertThat(model.getValueAt(0, 1)).as("row 0 col 1").isEqualTo(2); + assertThat(model.getValueAt(1, 0)).as("row 1 col 0").isEqualTo(3); + assertThat(model.getValueAt(1, 1)).as("row 1 col 1").isEqualTo(4); + } + + @Test + @DisplayName("the trailing newline does not shift the block or repeat it") + void trailingNewlineDoesNotChangeTheGeometry() + { + // the same paste with and without the terminator must produce the same grid: the + // extra line would otherwise inflate the row count and change how many times the + // block is tiled down the selection + TestSheet.Model with = pasteInto(TestSheet.numericGrid(4, MIN, MAX), "1\t2\r\n3\t4\r\n"); + TestSheet.Model without = pasteInto(TestSheet.numericGrid(4, MIN, MAX), "1\t2\r\n3\t4"); + + assertThat(with.snapshot()).isDeepEqualTo(without.snapshot()); + } + + @Test + @DisplayName("a blank cell leaves the sheet's value alone rather than being parsed") + void blankCellsAreSkipped() + { + // there is no number spelled "", so the only two options are to refuse the paste or to + // read the cell as absent. absent is what a spreadsheet means by it. + TestSheet.Model model = pasteInto(TestSheet.numericGrid(4, MIN, MAX), "1\t\r\n\t4"); + + assertThat(model.getValueAt(0, 0)).isEqualTo(1); + assertThat(model.getValueAt(1, 1)).isEqualTo(4); + assertThat(String.valueOf(model.getValueAt(0, 1))) + .as("a blank cell must not overwrite what was there") + .isEqualTo(String.valueOf(TestSheet.UNTOUCHED)); + assertThat(String.valueOf(model.getValueAt(1, 0))) + .as("a blank cell must not overwrite what was there") + .isEqualTo(String.valueOf(TestSheet.UNTOUCHED)); + } + + @Test + @DisplayName("one out-of-range cell refuses the whole paste, leaving every cell untouched") + void oneBadValueWritesNothing() + { + // the write loop cannot roll back and there is no undo, so a paste that gave up part + // way through would leave the sheet holding some of the block and not the rest, with + // nothing to say which. 300 does not fit the declared range. + TestSheet.Model model = TestSheet.numericGrid(4, MIN, MAX); + String[][] before = model.snapshot(); + + pasteInto(model, "1\t2\r\n300\t4"); + + assertThat(model.snapshot()) + .as("no cell may be written when any cell is refused") + .isDeepEqualTo(before); + } + + @Test + @DisplayName("a name pasted into a numeric column refuses the whole paste") + void nonNumericTextWritesNothing() + { + // the sheet exports rendered text, so an exported column is full of names; pasting one + // back must fail cleanly rather than half-applying + TestSheet.Model model = TestSheet.numericGrid(4, MIN, MAX); + String[][] before = model.snapshot(); + + pasteInto(model, "1\t2\r\nBulbasaur\t4"); + + assertThat(model.snapshot()).isDeepEqualTo(before); + } + + @Test + @DisplayName("the bounds of the declared range are accepted") + void boundsAreAccepted() + { + // an exclusive comparison in the validator shows up here first + TestSheet.Model model = pasteInto(TestSheet.numericGrid(4, MIN, MAX), MIN + "\t" + MAX); + + assertThat(model.getValueAt(0, 0)).isEqualTo(MIN); + assertThat(model.getValueAt(0, 1)).isEqualTo(MAX); + } + + @Test + @DisplayName("a paste of only a newline changes nothing and does not throw") + void emptyClipboardIsHarmless() + { + TestSheet.Model model = TestSheet.numericGrid(4, MIN, MAX); + String[][] before = model.snapshot(); + + pasteInto(model, "\r\n"); + + assertThat(model.snapshot()).isDeepEqualTo(before); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/PasteGeometryTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/PasteGeometryTest.java new file mode 100644 index 0000000..2b27292 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/PasteGeometryTest.java @@ -0,0 +1,209 @@ +package io.github.turtleisaac.pokeditor.gui.sheets; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.DefaultTable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.event.ActionEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Paste has a geometry, and the geometry is the contract. A clipboard block of m rows by n + * columns is stamped into the sheet starting at the top left of the selection, repeated as many + * whole times as fit inside the selection, and never anywhere else. + *

+ * Every expected grid below is written out in full, including the cells that must stay marked + * "{@value TestSheet#UNTOUCHED}". That is deliberate: the damage this guards against was never a + * missing write, it was an extra one - a paste of two rows into a five row selection rounding up + * to three copies and overwriting the row below the selection, where the user was not looking. + */ +class PasteGeometryTest +{ + private static final String U = TestSheet.UNTOUCHED; + + /** + * Selects the given rectangle, puts {@code clipboard} on the system clipboard and fires the + * production paste action. + * + * @return the whole sheet afterwards, so containment can be asserted, not just coverage + */ + private static String[][] paste(int rows, int firstRow, int lastRow, int firstCol, int lastCol, String clipboard) + { + TestSheet.Model model = new TestSheet.Model(rows); + TestSheet.Table table = new TestSheet.Table(model); + table.setRowSelectionInterval(firstRow, lastRow); + table.setColumnSelectionInterval(firstCol, lastCol); + + SystemClipboardStub.withClipboardContents(clipboard, + () -> new DefaultTable.PasteAction(table).actionPerformed(new ActionEvent(table, ActionEvent.ACTION_PERFORMED, "paste"))); + + return model.snapshot(); + } + + /** + * The commonest spreadsheet gesture there is: click one cell, paste a block. All m x n cells + * have to land. Computing the copy count as a rounded ratio of selection to clipboard makes + * this one round to zero copies, so the paste did nothing at all and said nothing about it. + */ + @Test + @DisplayName("one selected cell and an m x n clipboard writes all m x n cells from that cell") + void singleCellSelectionTakesTheWholeClipboard() + { + String[][] sheet = paste(6, 1, 1, 1, 1, "A\tB\nC\tD\nE\tF"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {U, U, U, U, U, U}, + {U, "A", "B", U, U, U}, + {U, "C", "D", U, U, U}, + {U, "E", "F", U, U, U}, + {U, U, U, U, U, U}, + {U, U, U, U, U, U}, + }); + } + + @Test + @DisplayName("one selected cell and a single clipboard cell writes exactly that one cell") + void singleCellPasteWritesOneCell() + { + String[][] sheet = paste(3, 2, 2, 3, 3, "solo"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {U, U, U, U, U, U}, + {U, U, U, U, U, U}, + {U, U, U, "solo", U, U}, + }); + } + + /** + * Containment, vertically. Five selected rows hold two whole copies of a two row clipboard, + * not two and a half. Rounding 2.5 up writes a third copy, which spills one row past the + * bottom of the selection and silently overwrites an entry the user never selected. + */ + @Test + @DisplayName("a selection that is not a whole multiple of the clipboard never writes past its own last row") + void partialVerticalCopyIsNotRoundedUp() + { + String[][] sheet = paste(8, 0, 4, 0, 0, "P\nQ"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {"P", U, U, U, U, U}, + {"Q", U, U, U, U, U}, + {"P", U, U, U, U, U}, + {"Q", U, U, U, U, U}, + { U, U, U, U, U, U}, // still inside the selection, but no whole copy reaches it + { U, U, U, U, U, U}, // below the selection entirely - the row the old paste clobbered + { U, U, U, U, U, U}, + { U, U, U, U, U, U}, + }); + } + + /** The same argument along the other axis: five selected columns hold two copies of two, not three. */ + @Test + @DisplayName("a selection that is not a whole multiple of the clipboard never writes past its own last column") + void partialHorizontalCopyIsNotRoundedUp() + { + String[][] sheet = paste(2, 0, 0, 0, 4, "X\tY"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {"X", "Y", "X", "Y", U, U}, + { U, U, U, U, U, U}, + }); + } + + /** + * Tiling. When the selection is an exact multiple of the clipboard in both directions, the + * clipboard repeats to fill it exactly - every selected cell written, nothing outside. + */ + @Test + @DisplayName("a selection that is an exact multiple of the clipboard is tiled with it exactly") + void exactMultipleSelectionIsTiled() + { + String[][] sheet = paste(6, 0, 5, 0, 3, "A\tB\nC\tD"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {"A", "B", "A", "B", U, U}, + {"C", "D", "C", "D", U, U}, + {"A", "B", "A", "B", U, U}, + {"C", "D", "C", "D", U, U}, + {"A", "B", "A", "B", U, U}, + {"C", "D", "C", "D", U, U}, + }); + } + + /** + * Clipping. A paste anchored near the bottom right corner writes the part of the block that + * fits and drops the rest, rather than throwing an index out of bounds at the user. + */ + @Test + @DisplayName("a paste that runs off the bottom and right edges writes what fits and does not throw") + void pasteIsClippedAtTheSheetEdges() + { + assertThatCode(() -> { + String[][] sheet = paste(3, 2, 2, 4, 4, "1\t2\t3\n4\t5\t6\n7\t8\t9"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {U, U, U, U, U, U}, + {U, U, U, U, U, U}, + {U, U, U, U, "1", "2"}, + }); + }).doesNotThrowAnyException(); + } + + /** + * A selection smaller than the clipboard still gets one whole copy. Truncating the ratio to + * zero is the failure this rules out: two selected rows and a three row clipboard is a + * fraction less than one, and the user expects their three rows regardless. + */ + @Test + @DisplayName("a selection smaller than the clipboard still receives one whole copy of it") + void selectionSmallerThanClipboardStillPastesOnce() + { + String[][] sheet = paste(6, 0, 1, 0, 0, "P\nQ\nR"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {"P", U, U, U, U, U}, + {"Q", U, U, U, U, U}, + {"R", U, U, U, U, U}, + { U, U, U, U, U, U}, + { U, U, U, U, U, U}, + { U, U, U, U, U, U}, + }); + } + + /** + * The paste is anchored at the top left cell of the selection, not at the origin of the + * sheet, and it does not reach back above or to the left of that anchor. + */ + @Test + @DisplayName("the paste is anchored at the top left of the selection and never reaches above or left of it") + void pasteIsAnchoredAtTheSelectionOrigin() + { + String[][] sheet = paste(5, 3, 3, 2, 2, "Z"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {U, U, U, U, U, U}, + {U, U, U, U, U, U}, + {U, U, U, U, U, U}, + {U, U, "Z", U, U, U}, + {U, U, U, U, U, U}, + }); + } + + /** + * Empty clipboard cells are cells. A copied block whose middle column is blank must not + * collapse, or every column to its right lands one place too far left. + */ + @Test + @DisplayName("blank cells inside the clipboard block keep their place in the destination") + void blankClipboardCellsKeepTheirColumn() + { + String[][] sheet = paste(2, 0, 0, 0, 0, "A\t\tC"); + + assertThat(sheet).isDeepEqualTo(new String[][] { + {"A", "", "C", U, U, U}, + { U, U, U, U, U, U}, + }); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/SystemClipboardStub.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/SystemClipboardStub.java new file mode 100644 index 0000000..a4f3009 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/SystemClipboardStub.java @@ -0,0 +1,117 @@ +package io.github.turtleisaac.pokeditor.gui.sheets; + +import java.awt.Dialog; +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Frame; +import java.awt.Image; +import java.awt.KeyboardFocusManager; +import java.awt.PrintJob; +import java.awt.Toolkit; +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.awt.font.TextAttribute; +import java.awt.im.InputMethodHighlight; +import java.awt.image.ColorModel; +import java.awt.image.ImageObserver; +import java.awt.image.ImageProducer; +import java.lang.reflect.Field; +import java.net.URL; +import java.util.Map; +import java.util.Properties; + +/** + * Lends the running JVM a working system clipboard for the duration of one action. + *

+ * The paste action reads {@code Toolkit.getDefaultToolkit().getSystemClipboard()} directly, and + * a headless toolkit has no clipboard to give - it throws. Rather than change production code to + * open a seam, this swaps in a toolkit which delegates everything to the real one except the + * clipboard, which becomes an ordinary in-process {@link Clipboard}. The swap lasts only as long + * as the action being tested and is always undone. + */ +final class SystemClipboardStub +{ + private SystemClipboardStub() {} + + /** + * Runs {@code action} with {@code contents} on the system clipboard. + * + * @param contents the clipboard text, in the tab and newline separated form a spreadsheet copy produces + */ + static void withClipboardContents(String contents, Runnable action) + { + // force the focus manager and the real toolkit into existence first: parts of AWT + // require the platform toolkit's own type, and can only be initialised while it is installed + Toolkit real = Toolkit.getDefaultToolkit(); + KeyboardFocusManager.getCurrentKeyboardFocusManager(); + + Field toolkitField = toolkitField(); + DelegatingToolkit stub = new DelegatingToolkit(real); + stub.clipboard.setContents(new StringSelection(contents), null); + + try { + set(toolkitField, stub); + action.run(); + } + finally { + set(toolkitField, real); + } + } + + private static Field toolkitField() + { + try { + Field field = Toolkit.class.getDeclaredField("toolkit"); + field.setAccessible(true); + return field; + } + catch (NoSuchFieldException | RuntimeException e) { + throw new AssertionError("cannot reach java.awt.Toolkit's default instance to lend the test a clipboard; " + + "the surefire configuration must pass --add-opens java.desktop/java.awt=ALL-UNNAMED", e); + } + } + + private static void set(Field field, Toolkit value) + { + try { + field.set(null, value); + } + catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + + /** Everything is the real toolkit's answer except {@link #getSystemClipboard()}. */ + private static final class DelegatingToolkit extends Toolkit + { + private final Toolkit delegate; + private final Clipboard clipboard = new Clipboard("test system clipboard"); + + private DelegatingToolkit(Toolkit delegate) { this.delegate = delegate; } + + @Override public Clipboard getSystemClipboard() { return clipboard; } + + @Override public Dimension getScreenSize() { return delegate.getScreenSize(); } + @Override public int getScreenResolution() { return delegate.getScreenResolution(); } + @Override public ColorModel getColorModel() { return delegate.getColorModel(); } + @SuppressWarnings("deprecation") @Override public String[] getFontList() { return delegate.getFontList(); } + @SuppressWarnings("deprecation") @Override public FontMetrics getFontMetrics(Font font) { return delegate.getFontMetrics(font); } + @Override public void sync() { delegate.sync(); } + @Override public Image getImage(String filename) { return delegate.getImage(filename); } + @Override public Image getImage(URL url) { return delegate.getImage(url); } + @Override public Image createImage(String filename) { return delegate.createImage(filename); } + @Override public Image createImage(URL url) { return delegate.createImage(url); } + @Override public Image createImage(ImageProducer producer) { return delegate.createImage(producer); } + @Override public Image createImage(byte[] data, int offset, int length) { return delegate.createImage(data, offset, length); } + @Override public boolean prepareImage(Image image, int w, int h, ImageObserver o) { return delegate.prepareImage(image, w, h, o); } + @Override public int checkImage(Image image, int w, int h, ImageObserver o) { return delegate.checkImage(image, w, h, o); } + @Override public PrintJob getPrintJob(Frame frame, String title, Properties props) { return delegate.getPrintJob(frame, title, props); } + @Override public void beep() { delegate.beep(); } + @Override protected EventQueue getSystemEventQueueImpl() { return delegate.getSystemEventQueue(); } + @Override public boolean isModalityTypeSupported(Dialog.ModalityType type) { return delegate.isModalityTypeSupported(type); } + @Override public boolean isModalExclusionTypeSupported(Dialog.ModalExclusionType type) { return delegate.isModalExclusionTypeSupported(type); } + @SuppressWarnings("deprecation") @Override public Map mapInputMethodHighlight(InputMethodHighlight highlight) { return delegate.mapInputMethodHighlight(highlight); } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/TestSheet.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/TestSheet.java new file mode 100644 index 0000000..6ce2a20 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/TestSheet.java @@ -0,0 +1,122 @@ +package io.github.turtleisaac.pokeditor.gui.sheets; + +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.DefaultTable; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.FormatModel; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * The smallest sheet the production table will accept: a fixed grid of plain strings, no frozen + * columns, no cell editors, no text banks and no format data behind it. Everything about the + * sheet's own semantics is deliberately trivial so that a test which fails here has failed in the + * table, not in whatever a real sheet would have done with the value. + */ +final class TestSheet +{ + /** Fixed, because {@link FormatModel}'s constructor asks for the column count before a subclass field could hold it. */ + static final int COLUMNS = 6; + + static final String UNTOUCHED = "."; + + private TestSheet() {} + + enum NoProperties { NONE } + + static final class Model extends FormatModel + { + final Object[][] cells; + + Model(int rows) + { + super(Arrays.asList(new GenericFileData[rows]), Collections.emptyList()); + cells = new Object[rows][COLUMNS]; + for (Object[] row : cells) + Arrays.fill(row, UNTOUCHED); + } + + /** A snapshot of the grid, so an assertion cannot be fooled by later writes. */ + String[][] snapshot() + { + String[][] copy = new String[cells.length][COLUMNS]; + for (int row = 0; row < cells.length; row++) + for (int col = 0; col < COLUMNS; col++) + copy[row][col] = String.valueOf(cells[row][col]); + return copy; + } + + @Override public int getColumnCount() { return COLUMNS; } + @Override public Object getValueAt(int row, int column) { return cells[row][column]; } + @Override public void setValueAt(Object value, int row, int column) + { + // mirrors the real sheets, whose setValueAt reaches setValueFor and converts there. + // A double that stored the raw String would report a paste as landing while the + // conversion it is supposed to be exercising never ran. + cells[row][column] = prepareObjectForWriting(value, getCellType(column), + cellTypes == null ? null : getCellValueRange(column)); + } + @Override public String getColumnNameKey(int columnIndex) { return "hp"; } + @Override public FormatModel getFrozenColumnModel() { return null; } + /** + * The cell type each column reports. STRING by default, because most of the geometry + * tests only care about which cell a value lands in - but a sheet made entirely of + * STRING columns cannot exercise validation at all, since prepareObjectForWriting does + * nothing for text. A paste regression shipped behind exactly that gap, so a fixture + * can now declare real types. + */ + CellTypes[] cellTypes; + + @Override public CellTypes getCellType(int columnIndex) + { + return cellTypes == null ? CellTypes.STRING : cellTypes[columnIndex]; + } + + @Override public int[] getCellValueRange(int columnIndex) + { + return valueRange == null ? super.getCellValueRange(columnIndex) : valueRange; + } + + int[] valueRange; + @Override public Object getValueFor(int entryIdx, NoProperties property) { return null; } + @Override public void setValueFor(Object value, int entryIdx, NoProperties property) { } + } + + static final class Table extends DefaultTable + { + Table(Model model) + { + super(model, Collections.emptyList(), new int[] {40, 40, 40, 40, 40, 40}, null); + } + + @Override public Queue obtainTextSources(List textData) { return new LinkedList<>(); } + @Override public Class getDataClass() { return GenericFileData.class; } + } + + /** + * A grid whose columns are all numeric, bounded by the given inclusive range. Use this + * wherever a test needs the write path to actually convert and validate. + */ + static Model numericGrid(int rows, int min, int max) + { + Model model = new Model(rows); + model.cellTypes = new CellTypes[COLUMNS]; + Arrays.fill(model.cellTypes, CellTypes.INTEGER); + model.valueRange = new int[] {min, max}; + return model; + } + + /** A grid of the given size with every cell marked as never having been written. */ + static String[][] untouchedGrid(int rows) + { + String[][] grid = new String[rows][COLUMNS]; + for (String[] row : grid) + Arrays.fill(row, UNTOUCHED); + return grid; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_CountingQueue.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_CountingQueue.java new file mode 100644 index 0000000..d1b16c6 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_CountingQueue.java @@ -0,0 +1,43 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import java.util.Collection; +import java.util.LinkedList; + +/** + * A {@link java.util.Queue} which records how many elements have been drawn out of it. + *

+ * The queue returned by {@code DefaultTable.obtainTextSources} is positional: element k + * is the name list intended for the k-th text-consuming column. A positional protocol is only + * well defined if every element is consumed exactly once, so the number of removals is as much a + * part of the contract as the values themselves - one removal too few (or too many) shifts every + * later element onto the wrong column. + */ +public class E_CountingQueue extends LinkedList +{ + private int removals; + + public E_CountingQueue(Collection initial) + { + super(initial); + } + + @Override + public String[] remove() + { + removals++; + return super.remove(); + } + + @Override + public String[] poll() + { + removals++; + return super.poll(); + } + + /** how many elements have been drawn out of this queue */ + public int removals() + { + return removals; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_CustomCellSupplier.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_CustomCellSupplier.java new file mode 100644 index 0000000..e6b6162 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_CustomCellSupplier.java @@ -0,0 +1,104 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; + +import javax.swing.*; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +/** + * A {@link CellTypes.CustomCellFunctionSupplier} whose products remember, verbatim, the three + * arrays they were handed. That is what makes the text-source consumption bijection observable + * for {@code CellTypes.CUSTOM} columns, which - unlike combo box columns - expose no + * {@code setItems} of their own. + */ +public class E_CustomCellSupplier implements CellTypes.CustomCellFunctionSupplier +{ + private final List editorCalls = new ArrayList<>(); + private final List rendererCalls = new ArrayList<>(); + + @Override + public TableCellEditor getEditor(String[]... strings) + { + editorCalls.add(strings); + return new E_CustomEditor(strings); + } + + @Override + public TableCellRenderer getRenderer(String[]... strings) + { + rendererCalls.add(strings); + return new E_CustomRenderer(strings); + } + + /** every argument list {@link #getEditor} has ever been called with, in call order */ + public List editorCalls() + { + return editorCalls; + } + + /** every argument list {@link #getRenderer} has ever been called with, in call order */ + public List rendererCalls() + { + return rendererCalls; + } + + public static class E_CustomEditor extends AbstractCellEditor implements TableCellEditor + { + private final String[][] given; + + E_CustomEditor(String[][] given) + { + this.given = given; + } + + public String[][] given() + { + return given; + } + + @Override + public Object getCellEditorValue() + { + return 0; + } + + @Override + public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) + { + return new JLabel(); + } + } + + /** + * Shaped like the real custom renderers (a {@link DefaultTableCellRenderer} which returns + * {@code this}), because {@code DefaultTable.exportClean} casts the prepared component to + * {@code DefaultTableCellRenderer} and reads its text. + */ + public static class E_CustomRenderer extends DefaultTableCellRenderer + { + private final String[][] given; + + E_CustomRenderer(String[][] given) + { + this.given = given; + } + + public String[][] given() + { + return given; + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) + { + super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + setText("custom(" + given[0][0] + "," + given[1][0] + "," + given[2][0] + ")=" + value); + return this; + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_Entry.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_Entry.java new file mode 100644 index 0000000..42f7a68 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_Entry.java @@ -0,0 +1,83 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.BytesDataContainer; +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.gamedata.GameFiles; + +import java.nio.ByteBuffer; + +/** + * A minimal {@link GenericFileData} double: a fixed-width vector of ints and nothing else. + *

+ * It is deliberately free of validation and of any repeated/variable-length structure, so that + * a property which fails while using it can only be the fault of the code under test, never of + * the format. {@link #save()} and {@link #setData(BytesDataContainer)} are exact inverses of one + * another by construction, which is what lets a copy/paste round trip be asserted as an identity. + */ +public class E_Entry implements GenericFileData +{ + /** an arbitrary stable key; this double never goes near a real narc */ + static final GameFiles E_FILE = GameFiles.PERSONAL; + + private final int[] cells; + + private int saveCalls; + private int setDataCalls; + + public E_Entry(int width) + { + this.cells = new int[width]; + } + + public int width() + { + return cells.length; + } + + public int get(int idx) + { + return cells[idx]; + } + + public void set(int idx, int value) + { + cells[idx] = value; + } + + /** a defensive copy of the whole payload, for before/after purity comparisons */ + public int[] snapshot() + { + return cells.clone(); + } + + public int saveCalls() + { + return saveCalls; + } + + public int setDataCalls() + { + return setDataCalls; + } + + @Override + public void setData(BytesDataContainer files) + { + setDataCalls++; + ByteBuffer buf = ByteBuffer.wrap(files.get(E_FILE, null)); + int count = buf.getInt(); + for (int i = 0; i < count && i < cells.length; i++) + cells[i] = buf.getInt(); + } + + @Override + public BytesDataContainer save() + { + saveCalls++; + ByteBuffer buf = ByteBuffer.allocate(Integer.BYTES * (1 + cells.length)); + buf.putInt(cells.length); + for (int value : cells) + buf.putInt(value); + return new BytesDataContainer(E_FILE, null, buf.array()); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_LayoutModel.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_LayoutModel.java new file mode 100644 index 0000000..3d089d0 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_LayoutModel.java @@ -0,0 +1,277 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A {@link FormatModel} whose column layout is supplied by the test rather than baked in, so a + * deliberately awkward arrangement of cell types (a custom column in the middle, combo + * boxes on both sides of it, checkboxes and integers interleaved) can be built on demand. + *

+ * Every read is a pure function of {row, column} and the entry payload, so any difference in the + * model between two points in time is necessarily a write performed by the code under test. + *

+ * Column names are drawn from real keys in the sheet-strings bundle because {@code FormatModel}'s + * constructor resolves every column name eagerly; the choice of key carries no meaning here. + */ +public class E_LayoutModel extends FormatModel +{ + /** + * {@code FormatModel}'s constructor calls {@link #getColumnCount()} and + * {@link #getNumFrozenColumns()} before this subclass's fields exist, so the shape has to be + * parked somewhere the superclass constructor can already see it. + */ + private static final ThreadLocal PENDING_LAYOUT = new ThreadLocal<>(); + private static final ThreadLocal PENDING_FROZEN = new ThreadLocal<>(); + + static final String[] FROZEN_KEYS = {"id", "name"}; + static final String[] EDIT_KEYS = {"hp", "atk", "def", "speed", "spAtk", "spDef", "type", "catchRate", "expDrop", "move", "level", "hpEvYield"}; + + /** how many distinct values a combo column's value may take; keeps indices inside the name lists */ + public static final int VALUE_MODULUS = 3; + + private CellTypes[] layout; + private Integer frozen; + + private E_LayoutModel(List data) + { + super(data, Collections.emptyList()); + this.layout = PENDING_LAYOUT.get(); + this.frozen = PENDING_FROZEN.get(); + } + + public static E_LayoutModel create(CellTypes[] layout, int frozenColumnCount, int rowCount) + { + PENDING_LAYOUT.set(layout); + PENDING_FROZEN.set(frozenColumnCount); + try + { + List entries = new ArrayList<>(); + for (int row = 0; row < rowCount; row++) + { + E_Entry entry = new E_Entry(layout.length); + for (int col = 0; col < layout.length; col++) + entry.set(col, (row + col) % VALUE_MODULUS); + entries.add(entry); + } + return new E_LayoutModel(entries); + } + finally + { + PENDING_LAYOUT.remove(); + PENDING_FROZEN.remove(); + } + } + + private CellTypes[] layout() + { + return layout != null ? layout : PENDING_LAYOUT.get(); + } + + private int frozen() + { + return frozen != null ? frozen : PENDING_FROZEN.get(); + } + + public CellTypes[] layoutArray() + { + return layout(); + } + + @Override + public int getNumFrozenColumns() + { + return frozen(); + } + + @Override + public int getColumnCount() + { + return layout().length; + } + + @Override + public String getColumnNameKey(int columnIndex) + { + if (columnIndex < 0) + return FROZEN_KEYS[Math.floorMod(frozen() + columnIndex, FROZEN_KEYS.length)]; + return EDIT_KEYS[columnIndex % EDIT_KEYS.length]; + } + + @Override + public CellTypes getCellType(int columnIndex) + { + return layout()[columnIndex]; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + CellTypes type = layout()[columnIndex]; + if (type == CellTypes.CHECKBOX) + return ((rowIndex + columnIndex) % 2) == 0; + if (type == CellTypes.STRING) + return "s" + rowIndex + ":" + columnIndex; + return getData().get(rowIndex).get(columnIndex); + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) + { + CellTypes type = layout()[columnIndex]; + if (type == CellTypes.STRING) + return; + if (type == CellTypes.CHECKBOX) + { + boolean flag = aValue instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(aValue)); + getData().get(rowIndex).set(columnIndex, flag ? 1 : 0); + return; + } + getData().get(rowIndex).set(columnIndex, Integer.parseInt(String.valueOf(aValue).trim())); + } + + @Override + public Object getValueFor(int entryIdx, Column property) + { + throw new UnsupportedOperationException("this double addresses cells by index, not by property"); + } + + @Override + public void setValueFor(Object aValue, int entryIdx, Column property) + { + throw new UnsupportedOperationException("this double addresses cells by index, not by property"); + } + + @Override + public FormatModel getFrozenColumnModel() + { + return frozen() == 0 ? null : E_FrozenModel.create(frozen(), getData()); + } + + public enum Column + { + CELL + } + + /** + * The parallel model behind the frozen ID/Name columns. It has the shape the real ones have - + * as many columns as the parent declares frozen - and returns values which name their own + * coordinates, so an export can be checked for having actually visited them. + */ + public static class E_FrozenModel extends FormatModel + { + private static final ThreadLocal PENDING_WIDTH = new ThreadLocal<>(); + + private Integer width; + + private E_FrozenModel(List data) + { + super(data, Collections.emptyList()); + this.width = PENDING_WIDTH.get(); + } + + static E_FrozenModel create(int width, List data) + { + PENDING_WIDTH.set(width); + try + { + return new E_FrozenModel(data); + } + finally + { + PENDING_WIDTH.remove(); + } + } + + private int width() + { + return width != null ? width : PENDING_WIDTH.get(); + } + + /** the value frozen cell (row, column) is defined to hold; computed by the test too */ + public static String frozenValue(int row, int column) + { + return "frz[" + row + "," + column + "]"; + } + + /** the human-readable name frozen column {@code column} is defined to carry */ + public static String frozenName(int column) + { + return "FROZEN#" + column; + } + + @Override + public int getNumFrozenColumns() + { + return width(); + } + + @Override + public int getColumnCount() + { + return width(); + } + + @Override + public String getColumnNameKey(int columnIndex) + { + if (columnIndex < 0) + return FROZEN_KEYS[Math.floorMod(width() + columnIndex, FROZEN_KEYS.length)]; + return EDIT_KEYS[columnIndex % EDIT_KEYS.length]; + } + + @Override + public String getColumnName(int column) + { + // mirrors the production frozen wrappers: this model presents only the frozen + // columns, so its column 0 is the sheet's first frozen column, and the offset + // FormatModel.getColumnName adds has to be undone. The double is only useful while + // it has the same shape as the thing it stands in for. + // + // width(), not super.getNumFrozenColumns(): this double extends FormatModel directly, + // whose base getNumFrozenColumns() answers 0. The production wrappers extend a real + // sheet model whose own answer is already the frozen count, so there super. is right + // and here it is not. + return super.getColumnName(column - width()); + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + return frozenValue(rowIndex, columnIndex); + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) + { + } + + @Override + public Object getValueFor(int entryIdx, Column property) + { + throw new UnsupportedOperationException("this double addresses cells by index, not by property"); + } + + @Override + public void setValueFor(Object aValue, int entryIdx, Column property) + { + throw new UnsupportedOperationException("this double addresses cells by index, not by property"); + } + + @Override + public FormatModel getFrozenColumnModel() + { + return null; + } + } + + /** unused by this double, but referenced so the import is meaningful to a reader */ + static List noTextBanks() + { + return Collections.emptyList(); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_ProbeTable.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_ProbeTable.java new file mode 100644 index 0000000..ae66587 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/E_ProbeTable.java @@ -0,0 +1,89 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * A concrete {@link DefaultTable} whose text sources are self-identifying: the array at + * queue position k is {@code {"src#0", "src#1", "src#2"}}. Because every element names + * its own position, the array a column ends up holding is enough on its own to recover which + * queue position that column was served from - which is exactly what a consumption bijection is + * a statement about. + */ +public class E_ProbeTable extends DefaultTable +{ + /** + * How many source arrays the next {@code obtainTextSources} call hands out. It has to be + * static because {@code DefaultTable}'s constructor calls {@code obtainTextSources} before any + * field of this subclass has been assigned. Tests set it from their own reference mapping and + * reset it in {@code @BeforeEach}. + */ + public static int sourceCount = 0; + + /** + * When non-negative, the element handed out at this queue position is {@code null} instead of + * a real array. {@code DefaultTable.getTextFromSource} documents a {@code new String[]{""}} + * fallback for exactly this case, and that fallback is only meaningful if a null element can + * actually reach it. + */ + public static int nullPosition = -1; + + /** deliberately has no initializer: a field initializer would run after the super constructor + * and would therefore discard the queue issued during construction */ + private List issued; + + public E_ProbeTable(E_LayoutModel model, int[] widths, CellTypes.CustomCellFunctionSupplier supplier) + { + super(model, new ArrayList<>(), widths, supplier); + } + + /** the array which occupies queue position {@code k} */ + public static String[] source(int k) + { + return new String[] {"src" + k + "#0", "src" + k + "#1", "src" + k + "#2"}; + } + + /** the queue position an array of the form produced by {@link #source(int)} came from */ + public static int positionOf(String[] array) + { + if (array == null || array.length == 0 || array[0] == null || !array[0].startsWith("src")) + return -1; + return Integer.parseInt(array[0].substring("src".length(), array[0].indexOf('#'))); + } + + @Override + public Queue obtainTextSources(List textData) + { + if (issued == null) + issued = new ArrayList<>(); + Queue contents = new LinkedList<>(); + for (int k = 0; k < sourceCount; k++) + contents.add(k == nullPosition ? null : source(k)); + E_CountingQueue queue = new E_CountingQueue(contents); + issued.add(queue); + return queue; + } + + /** every queue this table has handed out, in the order it handed them out */ + public List issuedQueues() + { + return issued == null ? new ArrayList<>() : issued; + } + + public E_CountingQueue lastQueue() + { + List queues = issuedQueues(); + return queues.get(queues.size() - 1); + } + + @Override + public Class getDataClass() + { + return E_Entry.class; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/EditorToWritePathTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/EditorToWritePathTest.java new file mode 100644 index 0000000..2996a9d --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/EditorToWritePathTest.java @@ -0,0 +1,140 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.TableCellComponents; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.JComboBox; +import javax.swing.JTable; +import javax.swing.table.TableCellEditor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * What an editor hands back has to be something the write path will accept. + *

+ * Both halves of this were already covered and both were green while the sheet was broken. The + * editors were tested on their own - does a cleared selection give back the original value - and + * {@link FormatModel#prepareObjectForWriting} was tested on its own, that a column refuses what + * it cannot store. What nobody tested was the join, and the join was the bug: type-to-search + * leaves a combo box with nothing selected whenever the typed text matches no entry exactly, the + * editor reported that as -1, and -1 is outside every column's range, so the range check + * rejected it. The user typed a move name and got an error dialog. + *

+ * So these tests run the editor the sheet actually installs for a column - through + * {@link TableCellComponents#forType}, so the wiring is covered too, not a hand-picked editor - + * and push whatever it produces into the write path for that same column. The assertion is the + * symptom the user reported: an edit that changed nothing must not raise anything, and must + * leave the cell as it was. + */ +class EditorToWritePathTest +{ + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private static final FormatModel MODEL = new WriteOnlyModel(); + + private static final String[][] MOVES = {{"Pound", "Karate Chop", "Double Slap", "Thunderbolt"}}; + + /** move IDs are 9 bits, so a sheet column offering move names declares this range */ + private static final int[] MOVE_RANGE = {0, 511}; + + private static TableCellEditor editorFor(CellTypes type, int[] range) + { + TableCellComponents.Pair pair = TableCellComponents.forType(type, MOVES, range, null); + assertThat(pair.editor()).as("the sheet installs an editor for a %s column", type).isNotNull(); + return pair.editor(); + } + + /** + * The reported bug, end to end, for every column type whose editor is a combo box. Each is + * opened on a value the column legally holds, then put into the state type-to-search leaves + * behind when the typed text matches nothing - and what comes out has to survive the write. + */ + @Test + @DisplayName("typing a name that matches nothing leaves the cell alone instead of erroring") + void aSearchThatMatchedNothingDoesNotReachTheRangeCheck() + { + for (CellTypes type : new CellTypes[] { + CellTypes.COMBO_BOX, CellTypes.COLORED_COMBO_BOX, CellTypes.BITFIELD_COMBO_BOX }) + { + // BITFIELD_COMBO_BOX stores a single set bit, so 4 is entry 3 there and entry 4 in + // the others; either way it is a value the column can hold and the cell already has + Object stored = 4; + TableCellEditor editor = editorFor(type, MOVE_RANGE); + + JComboBox box = (JComboBox) editor.getTableCellEditorComponent( + new JTable(), stored, false, 0, 0); + box.setSelectedIndex(-1); + + Object committed = editor.getCellEditorValue(); + + assertThatCode(() -> MODEL.prepareObjectForWriting(committed, type, MOVE_RANGE)) + .as("a %s edit that selected nothing must not be rejected by the write path - " + + "it produced %s", type, committed) + .doesNotThrowAnyException(); + + assertThat(MODEL.prepareObjectForWriting(committed, type, MOVE_RANGE)) + .as("and the cell must still hold what it held before the edit, for %s", type) + .isEqualTo(stored); + } + } + + /** + * The other end of the same join. A blank Learnsets cell is null, and the numeric editor used + * to hand back {@code String.valueOf(null)} - the four letters "null" - which the write path + * then refused as not a number. It now declines to commit at all, so nothing reaches the + * write path; this pins that, because "commits nothing" and "commits something harmless" are + * not the same and only the first avoids padding the row. + */ + @Test + @DisplayName("a blank numeric cell left alone never reaches the write path") + void aBlankNumericCellCommitsNothing() + { + TableCellEditor editor = editorFor(CellTypes.INTEGER, new int[] {0, 100}); + editor.getTableCellEditorComponent(new JTable(), null, false, 0, 0); + + assertThatCode(editor::stopCellEditing) + .as("no dialog for an edit that changed nothing") + .doesNotThrowAnyException(); + assertThat(editor.stopCellEditing()) + .as("a blank cell left blank must not be committed - the Learnsets write path pads " + + "every entry up to the one being set, so a write here invents moves") + .isFalse(); + } + + /** A genuine selection still has to survive the same trip, or the fix swallowed real edits. */ + @Test + @DisplayName("a real selection still reaches the write path unchanged") + void aRealSelectionStillCommits() + { + TableCellEditor editor = editorFor(CellTypes.COMBO_BOX, MOVE_RANGE); + + JComboBox box = (JComboBox) editor.getTableCellEditorComponent( + new JTable(), 0, false, 0, 0); + box.setSelectedIndex(3); + + assertThat(MODEL.prepareObjectForWriting(editor.getCellEditorValue(), CellTypes.COMBO_BOX, MOVE_RANGE)) + .isEqualTo(3); + } + + /** the smallest thing that can answer prepareObjectForWriting; no Core types involved */ + private static class WriteOnlyModel extends FormatModel + { + WriteOnlyModel() { super(java.util.List.of(), java.util.List.of()); } + + @Override public String getColumnNameKey(int columnIndex) { return null; } + @Override public int getColumnCount() { return 0; } + @Override public Object getValueAt(int rowIndex, int columnIndex) { return null; } + @Override public FormatModel getFrozenColumnModel() { return null; } + @Override public Object getValueFor(int rowIdx, CellTypes property) { return null; } + @Override public void setValueFor(Object aValue, int rowIdx, CellTypes property) { } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FakeEntry.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FakeEntry.java new file mode 100644 index 0000000..680e039 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FakeEntry.java @@ -0,0 +1,111 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.BytesDataContainer; +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.gamedata.GameFiles; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * A stand-in for a real sheet format which carries none of PokEditor-Core's semantics. + *

+ * It deliberately has the two shapes every sheet in this editor has to cope with: a couple of + * plain scalar fields, and a variable-length list of fixed-width groups. The second + * shape is the interesting one - it is what {@code LearnsetData} and {@code EvolutionData} are, + * and it is the shape which lets a table model be tempted into growing its own data while + * merely rendering a cell which lies past the end of the list. + *

+ * Nothing here validates or interprets anything: the point of the double is that a failure of + * the contract can only ever be the model's fault, never the format's. + */ +public class FakeEntry implements GenericFileData +{ + /** an arbitrary key - this double never goes near a real narc, it just needs a stable one */ + static final GameFiles FAKE_FILE = GameFiles.PERSONAL; + + /** how many ints one repeated group holds; mirrors the {move, level} pair of a learnset */ + public static final int GROUP_WIDTH = 2; + + private int alpha; + private int beta; + private final List repeated = new ArrayList<>(); + + public FakeEntry() + { + } + + public FakeEntry(int alpha, int beta) + { + this.alpha = alpha; + this.beta = beta; + } + + public int getAlpha() + { + return alpha; + } + + public void setAlpha(int alpha) + { + this.alpha = alpha; + } + + public int getBeta() + { + return beta; + } + + public void setBeta(int beta) + { + this.beta = beta; + } + + /** the live list - a model is expected to read it without lengthening it */ + public List getRepeated() + { + return repeated; + } + + public FakeEntry withGroups(int groupCount) + { + for (int i = 0; i < groupCount; i++) + { + repeated.add(new int[GROUP_WIDTH]); + } + return this; + } + + @Override + public void setData(BytesDataContainer files) + { + ByteBuffer buf = ByteBuffer.wrap(files.get(FAKE_FILE, null)); + alpha = buf.getInt(); + beta = buf.getInt(); + int groupCount = buf.getInt(); + repeated.clear(); + for (int i = 0; i < groupCount; i++) + { + int[] group = new int[GROUP_WIDTH]; + for (int j = 0; j < GROUP_WIDTH; j++) + group[j] = buf.getInt(); + repeated.add(group); + } + } + + @Override + public BytesDataContainer save() + { + ByteBuffer buf = ByteBuffer.allocate(Integer.BYTES * (3 + repeated.size() * GROUP_WIDTH)); + buf.putInt(alpha); + buf.putInt(beta); + buf.putInt(repeated.size()); + for (int[] group : repeated) + { + for (int value : group) + buf.putInt(value); + } + return new BytesDataContainer(FAKE_FILE, null, buf.array()); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FakeModel.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FakeModel.java new file mode 100644 index 0000000..544689b --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FakeModel.java @@ -0,0 +1,251 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; + +import java.util.ArrayList; +import java.util.List; + +/** + * A model which is deliberately correct, so that {@link FormatModelContract} can be + * shown to be satisfiable rather than merely unsatisfiable-in-practice. It has the same + * repeated-column shape as the learnsets and evolutions sheets, so every property in the + * contract has something real to bite on here: + *

    + *
  • reads never touch the entry list's length,
  • + *
  • the repeated group being addressed is model state, not state shared between every + * instance of the column enum, so two sheets open at once cannot read each other's + * column index,
  • + *
  • a write lands in one cell and nowhere else,
  • + *
  • the range a column advertises is the range it actually enforces.
  • + *
+ */ +public class FakeModel extends FormatModel +{ + /** how many repeated groups the grid makes room for, whatever an entry currently holds */ + public static final int MAX_GROUPS = 4; + + /** + * Which repeated group the column index currently being served refers to. + *

+ * The real sheets keep this on the column enum, where it is shared by every model in the + * process. Keeping it per-model is the corrected version of that: it is still written + * before each dispatch, but one sheet's painting cannot perturb another's. + */ + private int group; + + public FakeModel(List data, List textBankData) + { + super(data, textBankData); + } + + /** the repeated group the call currently in flight is addressing */ + protected int currentGroup() + { + return group; + } + + @Override + public int getNumFrozenColumns() + { + return 2; + } + + @Override + public String getColumnNameKey(int columnIndex) + { + return FakeColumn.getColumn(columnIndex).key; + } + + @Override + public int getColumnCount() + { + return MAX_GROUPS * FakeColumn.NUMBER_OF_COLUMNS.idx; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + if (columnIndex >= 0) + { + group = columnIndex / FakeColumn.NUMBER_OF_COLUMNS.idx; + return getValueFor(rowIndex, FakeColumn.getColumn(columnIndex % FakeColumn.NUMBER_OF_COLUMNS.idx)); + } + return getValueFor(rowIndex, FakeColumn.getColumn(columnIndex)); + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) + { + if (columnIndex >= 0) + { + group = columnIndex / FakeColumn.NUMBER_OF_COLUMNS.idx; + setValueFor(aValue, rowIndex, FakeColumn.getColumn(columnIndex % FakeColumn.NUMBER_OF_COLUMNS.idx)); + return; + } + setValueFor(aValue, rowIndex, FakeColumn.getColumn(columnIndex)); + } + + @Override + public Object getValueFor(int entryIdx, FakeColumn property) + { + FakeEntry entry = getData().get(entryIdx); + + if (property.idx >= 0) + { + // a cell past the end of this entry's list simply has nothing in it. Filling the + // list in so that there is something to return would make looking at the sheet + // change the file it is showing. + if (currentGroup() >= entry.getRepeated().size()) + return null; + return entry.getRepeated().get(currentGroup())[property.idx]; + } + + if (property == FakeColumn.ID) + return entryIdx; + + if (property == FakeColumn.NAME) + { + TextBankData names = getNameTextBank(); + return entryIdx < names.size() ? names.get(entryIdx).getText() : ""; + } + + return null; + } + + @Override + public void setValueFor(Object aValue, int entryIdx, FakeColumn property) + { + FakeEntry entry = getData().get(entryIdx); + aValue = prepareObjectForWriting(aValue, property.cellType); + + if (property.idx >= 0) + { + int value = (Integer) aValue; + int[] range = property.getValueRange(); + // the range this column advertises to the cell editors is the one the write path + // enforces, so a value which arrives by some other route (a paste, say) cannot be + // stored only to be truncated when the file is written back out + if (value < range[0] || value > range[1]) + throw new IllegalArgumentException(property + " accepts " + range[0] + ".." + range[1] + ", got " + value); + + while (currentGroup() >= entry.getRepeated().size()) + entry.getRepeated().add(new int[FakeEntry.GROUP_WIDTH]); + + entry.getRepeated().get(currentGroup())[property.idx] = value; + return; + } + + if (property == FakeColumn.NAME) + getNameTextBank().get(entryIdx).setText(String.valueOf(aValue)); + } + + @Override + protected CellTypes getCellType(int columnIndex) + { + if (columnIndex >= 0) + return FakeColumn.getColumn(columnIndex % FakeColumn.NUMBER_OF_COLUMNS.idx).cellType; + return FakeColumn.getColumn(columnIndex).cellType; + } + + @Override + public int[] getCellValueRange(int columnIndex) + { + if (columnIndex >= 0) + return FakeColumn.getColumn(columnIndex % FakeColumn.NUMBER_OF_COLUMNS.idx).getValueRange(); + return FakeColumn.getColumn(columnIndex).getValueRange(); + } + + @Override + public TextBankData getNameTextBank() + { + return getTextBankData().get(0); + } + + @Override + public FormatModel getFrozenColumnModel() + { + return new FakeModel(getData(), getTextBankData()) { + @Override + public int getColumnCount() + { + return super.getNumFrozenColumns(); + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + return super.getValueAt(rowIndex, columnIndex - super.getNumFrozenColumns()); + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) + { + super.setValueAt(aValue, rowIndex, columnIndex - super.getNumFrozenColumns()); + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) + { + return false; + } + }; + } + + /** convenience for tests: a sheet of {@code rowCount} entries, each already fully populated */ + public static List populatedEntries(int rowCount) + { + List entries = new ArrayList<>(); + for (int row = 0; row < rowCount; row++) + entries.add(new FakeEntry(row, row * 2).withGroups(MAX_GROUPS)); + return entries; + } + + /** convenience for tests: a sheet of {@code rowCount} entries which hold no groups at all */ + public static List emptyEntries(int rowCount) + { + List entries = new ArrayList<>(); + for (int row = 0; row < rowCount; row++) + entries.add(new FakeEntry(row, row * 2)); + return entries; + } + + public enum FakeColumn + { + ID(-2, "id", CellTypes.INTEGER), + NAME(-1, "name", CellTypes.STRING), + FIRST(0, "move", CellTypes.COMBO_BOX), + SECOND(1, "level", CellTypes.INTEGER), + NUMBER_OF_COLUMNS(2, null, null); + + final int idx; + final String key; + final CellTypes cellType; + + FakeColumn(int idx, String key, CellTypes cellType) + { + this.idx = idx; + this.key = key; + this.cellType = cellType; + } + + int[] getValueRange() + { + return switch (this) { + case FIRST -> new int[] {0, 511}; + case SECOND -> new int[] {0, 127}; + default -> new int[] {0, 0xFFFF}; + }; + } + + static FakeColumn getColumn(int idx) + { + for (FakeColumn column : values()) + { + if (column.idx == idx) + return column; + } + return NUMBER_OF_COLUMNS; + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModelContract.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModelContract.java new file mode 100644 index 0000000..64f8f55 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModelContract.java @@ -0,0 +1,560 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * The properties every {@link FormatModel} has to have by virtue of being a table model at all, + * expressed so that they can be pointed at any model - the real sheets, or a test double. + *

+ * None of these assertions encode what any particular sheet currently returns. They are all + * statements about the relationship between reading a cell, writing a cell and the + * data underneath, which is what a spreadsheet is; a model which fails one of them is broken + * regardless of what the numbers in it happen to be. + *

+ * Every failure message names the model, the cell, and the values involved, because a red + * assertion on a 4x100 grid which does not say which cell moved costs more time than + * it saves. + */ +public final class FormatModelContract +{ + /** how many times the whole grid is swept when checking that reading changes nothing */ + private static final int READ_PASSES = 3; + + /** how many range violations are spelled out before the rest are merely counted */ + private static final int MAX_LISTED_VIOLATIONS = 12; + + /** guards the reflective fingerprint against a pathological object graph */ + private static final int MAX_FINGERPRINT_DEPTH = 8; + + private FormatModelContract() + { + } + + // ------------------------------------------------------------------ properties + + /** + * Observing a model must not change it. + *

+ * Swing calls {@code getValueAt} for every visible cell on every repaint, and again for + * every cell when a sheet is exported. If any of those reads writes to the data underneath, + * then simply looking at a file corrupts it, silently, and the corruption compounds every + * time the file is opened. That is precisely what the learnsets sheet did when its read + * path padded the learnset out to the column being painted. + *

+ * The whole grid is read several times over, including the frozen columns (which live at + * negative indices), and the model's data is fingerprinted before and after each + * cell so the offending cell can be named. + */ + public static void assertReadsArePure(FormatModel model) + { + String pristine = fingerprintOf(model); + + for (int pass = 0; pass < READ_PASSES; pass++) + { + for (int row = 0; row < model.getRowCount(); row++) + { + for (int col = firstColumn(model); col < model.getColumnCount(); col++) + { + Object value = model.getValueAt(row, col); + String now = fingerprintOf(model); + if (!now.equals(pristine)) + { + fail("reading %s (sweep %d) returned <%s> and changed the data underneath it." + + " A read reached from painting must never mutate what it observes.%n%s", + cell(model, row, col), pass, value, difference(pristine, now)); + } + } + } + } + } + + /** + * Reading a cell twice gives the same answer, whatever is read in between. + *

+ * The repeated-column sheets identify which repetition a column refers to by writing into a + * field before dispatching to the read. When that field lives somewhere shared - on the + * column enum, say, where every model in the process sees the same one - then reading any + * other cell first can change what a cell reports, and a repaint reads every cell. The + * symptom is a sheet which shows one entry's move under another entry's level. + */ + public static void assertReadsAreIdempotent(FormatModel model) + { + for (int row = 0; row < model.getRowCount(); row++) + { + for (int col = firstColumn(model); col < model.getColumnCount(); col++) + { + Object first = model.getValueAt(row, col); + Object again = model.getValueAt(row, col); + assertThat(again) + .as("%s returned <%s> and then, with nothing in between, <%s>", cell(model, row, col), first, again) + .isEqualTo(first); + + sweep(model, row, col); + + Object afterSweep = model.getValueAt(row, col); + assertThat(afterSweep) + .as("%s returned <%s>, but after the rest of the sheet was read - which is all a repaint does -" + + " the same cell returned <%s>. Which cell a read refers to must not be carried in state" + + " that other reads overwrite.", + cell(model, row, col), first, afterSweep) + .isEqualTo(first); + } + } + } + + /** + * A write lands in one cell and nowhere else. + *

+ * This is the table analogue of writing one pixel: it catches an index translation which is + * off by the frozen-column count, a repetition index computed from the wrong column, or a + * write which reaches through into a neighbour. Those are the errors which make a user's + * edit appear to work while quietly overwriting a different Pokemon's data. + */ + public static void assertWritesAreLocal(FormatModel model, int row, int col, Object value) + { + Object[][] before = readGrid(model); + + model.setValueAt(value, row, col); + + Object landed = model.getValueAt(row, col); + assertThat(sameCellValue(value, landed)) + .as("wrote <%s> into %s, which now reads back <%s>", value, cell(model, row, col), landed) + .isTrue(); + + Object[][] after = readGrid(model); + int offset = -firstColumn(model); + for (int r = 0; r < after.length; r++) + { + for (int c = firstColumn(model); c < model.getColumnCount(); c++) + { + if (r == row && c == col) + continue; + Object was = before[r][c + offset]; + Object is = after[r][c + offset]; + assertThat(is) + .as("writing <%s> into %s also changed %s, which held <%s> and now holds <%s>", + value, cell(model, row, col), cell(model, r, c), was, is) + .isEqualTo(was); + } + } + } + + /** + * What you put into a cell is what the cell then shows. + *

+ * A model which accepts a value and then renders something else has lost the user's edit; + * a model which accepts it and renders it only until the next repaint has lost it more + * subtly. The read is taken twice for that reason. + */ + public static void assertWriteRoundTrips(FormatModel model, int row, int col, Object value) + { + model.setValueAt(value, row, col); + + Object read = model.getValueAt(row, col); + assertThat(sameCellValue(value, read)) + .as("wrote <%s> into %s, read back <%s>", value, cell(model, row, col), read) + .isTrue(); + + Object reread = model.getValueAt(row, col); + assertThat(sameCellValue(value, reread)) + .as("wrote <%s> into %s, which read back <%s> once and <%s> the second time", + value, cell(model, row, col), read, reread) + .isTrue(); + } + + /** + * Every cell the model says exists can be rendered. + *

+ * {@code getRowCount()} and {@code getColumnCount()} are a promise to Swing, which will ask + * for every one of those cells without being asked twice. A cell inside the declared grid + * which throws is not a caught error, it is an exception on the paint thread: the sheet is + * left half-drawn and the editor has to be killed. + */ + public static void assertEveryCellIsReadable(FormatModel model) + { + for (int row = 0; row < model.getRowCount(); row++) + { + for (int col = firstColumn(model); col < model.getColumnCount(); col++) + { + try + { + model.getValueAt(row, col); + } + catch (Throwable t) + { + fail("%s is inside the %d x %d grid the model declares, but reading it threw %s: %s", + cell(model, row, col), model.getRowCount(), model.getColumnCount(), + t.getClass().getName(), t.getMessage()); + } + } + } + } + + /** + * The range a column advertises is the range that column really has. + *

+ * {@code getCellValueRange} is what the cell editors use to decide which keystrokes to + * refuse, so it is a promise made to the user. If a bound cannot actually be stored, the + * editor happily accepts a value the write path then rejects with a dialog; if a value + * outside the range can be stored, it survives until save time and is truncated into a + * different, plausible-looking value - a move that becomes a different move. + *

+ * Checkbox and free-text columns have no numeric range to be honest about and are skipped. + */ + public static void assertValueRangesAreHonest(FormatModel model) + { + List violations = new ArrayList<>(); + + for (int col = 0; col < model.getColumnCount(); col++) + { + int[] range = model.getCellValueRange(col); + + // a malformed range is not a violation to collect, it is a broken promise the cell + // editors cannot even be built from, so it fails on the spot + assertThat(range) + .as("%s declares no value range for column %d", name(model), col) + .isNotNull(); + assertThat(range.length) + .as("%s column %d must declare a range as {min, max}, but declared %d value(s)", + name(model), col, range.length) + .isEqualTo(2); + assertThat(range[0]) + .as("%s column %d declares the empty range {%d, %d}", name(model), col, range[0], range[1]) + .isLessThanOrEqualTo(range[1]); + + if (!hasNumericRange(model, col)) + continue; + + for (int bound : new int[] {range[0], range[1]}) + collectBoundViolation(model, col, range, bound, violations); + + collectOutOfRangeViolation(model, col, range, range[1] + 1, violations); + if (range[0] > Integer.MIN_VALUE) + collectOutOfRangeViolation(model, col, range, range[0] - 1, violations); + } + + if (!violations.isEmpty()) + { + // more than the first offending column is listed, so that one red run says how far + // the problem reaches instead of having to be re-run column by column + List listed = violations.subList(0, Math.min(violations.size(), MAX_LISTED_VIOLATIONS)); + String tail = violations.size() > listed.size() + ? String.format("%n ... and %d more", violations.size() - listed.size()) + : ""; + fail("%s does not hold the value ranges it advertises (%d violation(s)):%n %s%s", + name(model), violations.size(), String.join(String.format("%n "), listed), tail); + } + } + + // ------------------------------------------------------------------ internals + + /** the column has to be able to hold the bound it tells the cell editor it can hold */ + private static void collectBoundViolation(FormatModel model, int col, int[] range, int bound, List violations) + { + try + { + model.setValueAt(bound, 0, col); + } + catch (RuntimeException e) + { + violations.add(String.format("%s advertises %d..%d, but writing the advertised bound <%d> was rejected: %s: %s", + cell(model, 0, col), range[0], range[1], bound, e.getClass().getName(), e.getMessage())); + return; + } + + Object read = model.getValueAt(0, col); + if (!sameCellValue(bound, read)) + { + violations.add(String.format("%s advertises %d..%d; the advertised bound <%d> was written and <%s> read back", + cell(model, 0, col), range[0], range[1], bound, read)); + } + } + + /** a value the column cannot hold has to be refused where it is written, not at save time */ + private static void collectOutOfRangeViolation(FormatModel model, int col, int[] range, int outside, List violations) + { + try + { + model.setValueAt(outside, 0, col); + } + catch (RuntimeException refused) + { + return; // refused loudly, which is the whole point + } + + Object read = model.getValueAt(0, col); + Integer numeric = asInt(read); + if (numeric == null || numeric < range[0] || numeric > range[1]) + { + violations.add(String.format("%s advertises %d..%d, but accepted <%d> without complaint and now reads back <%s>" + + " - a value the column cannot hold has to be refused where it is written, not stored until" + + " save silently truncates it into a different value", + cell(model, 0, col), range[0], range[1], outside, read)); + } + } + + /** the leftmost column index of the grid, which is negative when the sheet has frozen columns */ + private static int firstColumn(FormatModel model) + { + return -model.getNumFrozenColumns(); + } + + private static void sweep(FormatModel model, int skipRow, int skipCol) + { + for (int row = 0; row < model.getRowCount(); row++) + { + for (int col = firstColumn(model); col < model.getColumnCount(); col++) + { + if (row == skipRow && col == skipCol) + continue; + model.getValueAt(row, col); + } + } + } + + private static Object[][] readGrid(FormatModel model) + { + int offset = -firstColumn(model); + Object[][] grid = new Object[model.getRowCount()][model.getColumnCount() + offset]; + for (int row = 0; row < grid.length; row++) + { + for (int col = firstColumn(model); col < model.getColumnCount(); col++) + { + grid[row][col + offset] = model.getValueAt(row, col); + } + } + return grid; + } + + private static boolean hasNumericRange(FormatModel model, int col) + { + CellTypes type = model.getCellType(col); + return type != null && type != CellTypes.CHECKBOX && type != CellTypes.STRING; + } + + private static Integer asInt(Object value) + { + if (value instanceof Integer i) + return i; + if (value instanceof Number n) + return n.intValue(); + if (value instanceof String s) + { + try + { + return Integer.valueOf(s.trim()); + } + catch (NumberFormatException ignored) + { + return null; + } + } + return null; + } + + /** + * Whether a value written into a cell and a value read back out of it are the same value. + * A sheet is edited with text and stores numbers, so {@code "42"} and {@code 42} are the + * same cell contents; anything else is a difference. + */ + private static boolean sameCellValue(Object written, Object read) + { + if (Objects.equals(written, read)) + return true; + if (written == null || read == null) + return false; + + Integer a = asInt(written); + Integer b = asInt(read); + if (a != null && b != null) + return a.equals(b); + + if (written instanceof Boolean || read instanceof Boolean) + return String.valueOf(written).equalsIgnoreCase(String.valueOf(read)); + + return false; + } + + private static String name(FormatModel model) + { + String simple = model.getClass().getSimpleName(); + return simple.isEmpty() ? model.getClass().getName() : simple; + } + + private static String cell(FormatModel model, int row, int col) + { + return String.format("%s cell (row %d, column %d)", name(model), row, col); + } + + // ------------------------------------------------------ deep fingerprint + + /** + * A textual fingerprint of everything the model is showing: the entry list, the length and + * contents of every nested collection and array inside each entry, and the text banks the + * frozen columns are drawn from. + *

+ * It is built by reflection rather than by calling anything on the formats themselves, so + * that the comparison cannot be defeated by a format whose {@code equals} is inherited from + * {@code ArrayList} (and so ignores an appended default-valued entry's meaning) and + * so that it does not depend on any format serialising correctly. + */ + private static String fingerprintOf(FormatModel model) + { + StringBuilder sb = new StringBuilder(); + sb.append("data="); + fingerprint(model.getData(), sb, new IdentityHashMap<>(), 0); + sb.append(" text="); + fingerprint(model.getTextBankData(), sb, new IdentityHashMap<>(), 0); + return sb.toString(); + } + + private static void fingerprint(Object value, StringBuilder sb, IdentityHashMap seen, int depth) + { + if (value == null) + { + sb.append("null"); + return; + } + + Class type = value.getClass(); + if (type.isPrimitive() || value instanceof Number || value instanceof Boolean + || value instanceof Character || value instanceof CharSequence || value instanceof Enum) + { + sb.append(value); + return; + } + + if (depth > MAX_FINGERPRINT_DEPTH) + { + sb.append("..."); + return; + } + + if (seen.put(value, value) != null) + { + sb.append(""); + return; + } + + try + { + if (type.isArray()) + { + int length = Array.getLength(value); + sb.append('[').append(length).append(':'); + for (int i = 0; i < length; i++) + { + if (i > 0) + sb.append(','); + fingerprint(Array.get(value, i), sb, seen, depth + 1); + } + sb.append(']'); + return; + } + + if (value instanceof Collection collection) + { + // the size is the part which the learnsets bug moved, so it leads + sb.append('(').append(collection.size()).append(':'); + boolean first = true; + for (Object element : collection) + { + if (!first) + sb.append(','); + first = false; + fingerprint(element, sb, seen, depth + 1); + } + sb.append(')'); + // a format may be a list and still carry fields of its own + appendFields(value, sb, seen, depth); + return; + } + + if (value instanceof Map map) + { + sb.append('{').append(map.size()).append(':'); + for (Map.Entry entry : map.entrySet()) + { + fingerprint(entry.getKey(), sb, seen, depth + 1); + sb.append("->"); + fingerprint(entry.getValue(), sb, seen, depth + 1); + sb.append(';'); + } + sb.append('}'); + return; + } + + sb.append(type.getSimpleName()); + appendFields(value, sb, seen, depth); + } + finally + { + seen.remove(value); + } + } + + private static void appendFields(Object value, StringBuilder sb, IdentityHashMap seen, int depth) + { + List fields = new ArrayList<>(); + for (Class c = value.getClass(); c != null && !c.getName().startsWith("java."); c = c.getSuperclass()) + { + for (Field field : c.getDeclaredFields()) + { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) + continue; + fields.add(field); + } + } + if (fields.isEmpty()) + return; + + fields.sort(Comparator.comparing(Field::getName)); + sb.append('<'); + for (Field field : fields) + { + sb.append(field.getName()).append('='); + try + { + field.setAccessible(true); + fingerprint(field.get(value), sb, seen, depth + 1); + } + catch (ReflectiveOperationException | RuntimeException e) + { + sb.append(""); + } + sb.append(';'); + } + sb.append('>'); + } + + /** the neighbourhood of the first character at which two fingerprints diverge */ + private static String difference(String before, String after) + { + int i = 0; + while (i < before.length() && i < after.length() && before.charAt(i) == after.charAt(i)) + i++; + int from = Math.max(0, i - 60); + return String.format("first divergence at offset %d%n before: ...%s%n after: ...%s", + i, window(before, from), window(after, from)); + } + + private static String window(String s, int from) + { + int to = Math.min(s.length(), from + 200); + return s.substring(Math.min(from, s.length()), to) + (to < s.length() ? "..." : ""); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModelContractTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModelContractTest.java new file mode 100644 index 0000000..b8b544b --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FormatModelContractTest.java @@ -0,0 +1,161 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Validates {@link FormatModelContract} itself, against two models built for the purpose: + * one which is correct, and one which carries the learnsets bug. The first shows the contract + * is satisfiable; the second shows it is not vacuous. + */ +class FormatModelContractTest +{ + private static final int ROWS = 4; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + static List names(int count) + { + List messages = new ArrayList<>(); + for (int i = 0; i < count; i++) + messages.add(new TextBankData.Message("Entry " + i)); + List banks = new ArrayList<>(); + banks.add(new TextBankData(messages)); + return banks; + } + + private static FakeModel populatedModel() + { + return new FakeModel(FakeModel.populatedEntries(ROWS), names(ROWS)); + } + + // ------------------------------------------------------------ the double is coherent + + @Test + @DisplayName("the stand-in format reloads exactly the state it saved, so a snapshot of it means something") + void fakeEntryRoundTrips() + { + FakeEntry original = new FakeEntry(7, 9).withGroups(3); + original.getRepeated().get(1)[0] = 42; + original.getRepeated().get(2)[1] = 13; + + FakeEntry reloaded = new FakeEntry(); + reloaded.setData(original.save()); + + assertThat(reloaded.getAlpha()).isEqualTo(7); + assertThat(reloaded.getBeta()).isEqualTo(9); + assertThat(reloaded.getRepeated()).hasSize(3); + assertThat(reloaded.getRepeated().get(1)[0]).isEqualTo(42); + assertThat(reloaded.getRepeated().get(2)[1]).isEqualTo(13); + } + + // ------------------------------------------------------------ the contract holds on a correct model + + @Test + @DisplayName("reading every cell of a correct model leaves its data untouched") + void readsArePure() + { + FormatModelContract.assertReadsArePure(populatedModel()); + } + + @Test + @DisplayName("reading every cell of a correct model whose entries are empty still leaves it untouched") + void readsArePureOverAnEmptyTail() + { + // the control for the meta-test below: the grid is far wider than the entries are long, + // so every cell past the first is a cell a buggy model would be tempted to materialise + FormatModelContract.assertReadsArePure(new FakeModel(FakeModel.emptyEntries(ROWS), names(ROWS))); + } + + @Test + @DisplayName("a cell of a correct model reports the same value however much of the sheet is read around it") + void readsAreIdempotent() + { + FormatModelContract.assertReadsAreIdempotent(populatedModel()); + } + + @Test + @DisplayName("writing a cell of a correct model changes that cell and no other") + void writesAreLocal() + { + FormatModelContract.assertWritesAreLocal(populatedModel(), 1, 0, 300); + FormatModelContract.assertWritesAreLocal(populatedModel(), 2, 5, 99); + FormatModelContract.assertWritesAreLocal(populatedModel(), 0, 7, 1); + FormatModelContract.assertWritesAreLocal(populatedModel(), 3, -1, "Renamed"); + } + + @Test + @DisplayName("a value written into a correct model is the value that model then reports") + void writesRoundTrip() + { + FormatModelContract.assertWriteRoundTrips(populatedModel(), 0, 0, 511); + FormatModelContract.assertWriteRoundTrips(populatedModel(), 3, 6, 0); + FormatModelContract.assertWriteRoundTrips(populatedModel(), 2, 3, "64"); // as a cell editor delivers it + FormatModelContract.assertWriteRoundTrips(populatedModel(), 1, -1, "Renamed"); + } + + @Test + @DisplayName("every cell a correct model declares can be read without throwing") + void everyCellIsReadable() + { + FormatModelContract.assertEveryCellIsReadable(populatedModel()); + FormatModelContract.assertEveryCellIsReadable(new FakeModel(FakeModel.emptyEntries(ROWS), names(ROWS))); + } + + @Test + @DisplayName("a correct model can store both bounds of every range it advertises, and refuses everything outside them") + void valueRangesAreHonest() + { + FormatModelContract.assertValueRangesAreHonest(populatedModel()); + } + + // ------------------------------------------------------------ the contract has teeth + + @Test + @DisplayName("META: the purity property fails on a model whose read grows the entry it is reading") + void purityCatchesAGrowingRead() + { + MutatingReadModel buggy = new MutatingReadModel(FakeModel.emptyEntries(ROWS), names(ROWS)); + + assertThatThrownBy(() -> FormatModelContract.assertReadsArePure(buggy)) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("MutatingReadModel cell (row 0, column 0)") + .hasMessageContaining("changed the data underneath it"); + } + + @Test + @DisplayName("META: the growing read really does corrupt the data, not merely trip the fingerprint") + void theGrowingReadActuallyLengthensTheEntry() + { + List entries = FakeModel.emptyEntries(ROWS); + MutatingReadModel buggy = new MutatingReadModel(entries, names(ROWS)); + + assertThat(entries.get(0).getRepeated()).isEmpty(); + for (int col = 0; col < buggy.getColumnCount(); col++) + buggy.getValueAt(0, col); // one repaint of one row + + assertThat(entries.get(0).getRepeated()) + .as("merely painting row 0 appended entries which the user never typed") + .hasSize(FakeModel.MAX_GROUPS); + } + + @Test + @DisplayName("META: an otherwise identical model without the growing read passes the same property") + void theOnlyDifferenceIsTheGrowingRead() + { + // same fixture, same grid, same column enum - so the failure above can only be the read path + FormatModelContract.assertReadsArePure(new FakeModel(FakeModel.emptyEntries(ROWS), names(ROWS))); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTableTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTableTest.java new file mode 100644 index 0000000..2686a65 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/FrozenColumnTableTest.java @@ -0,0 +1,393 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.DataManager; +import io.github.turtleisaac.pokeditor.formats.GenericFileData; +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import javax.swing.event.TableModelListener; +import javax.swing.table.TableModel; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.ResourceBundle; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code FrozenColumnTable} is the left-hand, non-scrolling half of a sheet: the ID and Name + * columns. Three separate contracts meet in it. + * + *

    + *
  • The corner header names the frozen columns. The corner sits above the frozen block, + * so cell {@code c} of its single row is the header of frozen column {@code c}. Whatever + * index arithmetic it uses internally, the index it finally hands to + * {@code TableModel.getColumnName} must be a legal column index - {@code [0, columnCount)} - + * because that is the entire domain on which {@code getColumnName} is defined.
  • + *
  • A write is dirtying, and only a real write is. "Unsaved changes" is a claim about + * whether the in-memory data differs from what is on disk. A write which succeeded makes it + * true; a write which was rejected leaves it exactly as it was.
  • + *
  • A write is local. Writing cell (r, c) is an update of one coordinate. Every other + * coordinate is unchanged - the same injectivity property every cell write in this editor + * has to satisfy.
  • + *
+ */ +public class FrozenColumnTableTest +{ + private static final int ROWS = 4; + private static final int COLUMNS = 2; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @BeforeEach + void startClean() + { + DataManager.markClean(TextBankData.class); + } + + /** + * Whether {@code DataManager} currently considers the given data class dirty. {@code DataManager} + * exposes only a process-wide {@code hasUnsavedChanges()}, which any other test in the same JVM + * could have set, so the per-class set is read directly to keep this test independent of the + * rest of the suite. + */ + private static boolean dirty(Class dataClass) + { + try + { + Field field = DataManager.class.getDeclaredField("dirtyClasses"); + field.setAccessible(true); + return ((Set) field.get(null)).contains(dataClass); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("could not observe DataManager's dirty set", e); + } + } + + // ---------------------------------------------------------------- corner header + + @Test + @DisplayName("the corner header has one cell per frozen column") + void cornerHeaderIsAsWideAsTheFrozenBlock() + { + E_GridModel model = new E_GridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + JTable corner = table.getCornerTableHeader(); + + // PROPERTY: the corner is the header of the frozen block, so it is a 1 x frozenColumnCount + // strip. Any other width would leave a frozen column unlabelled or label a column that is + // not there. + assertThat(corner.getModel().getColumnCount()) + .as("the corner must have exactly one cell per frozen column") + .isEqualTo(table.getColumnModel().getColumnCount()); + assertThat(corner.getModel().getRowCount()) + .as("the corner is a single header row") + .isEqualTo(1); + } + + @Test + @DisplayName("the corner header only ever asks the model for legal column indices") + void cornerHeaderAsksForLegalColumnIndicesOnly() + { + E_GridModel model = new E_GridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + JTable corner = table.getCornerTableHeader(); + + model.columnNameQueries().clear(); + for (int column = 0; column < corner.getModel().getColumnCount(); column++) + corner.getModel().getValueAt(0, column); + + // PROPERTY (domain of getColumnName): TableModel.getColumnName is defined on [0, columnCount) + // and on nothing else. The corner is meant to name frozen column c, so the index it passes + // must BE c - it is asking "what is column c called?". The production expression is + // getModel().getColumnName(column - getColumnModel().getColumnCount()) + // which for column in [0, n) yields indices in [-n, -1]: every single one outside the domain. + // A model which answers out-of-domain indices at all is doing so by accident, so a header + // built this way is correct only by coincidence. + assertThat(model.columnNameQueries()) + .as("every index handed to getColumnName must be a legal column index") + .allSatisfy(index -> assertThat(index).isBetween(0, model.getColumnCount() - 1)); + } + + @Test + @DisplayName("the corner header cell for frozen column c is the name of frozen column c") + void cornerHeaderNamesTheFrozenColumns() + { + E_GridModel model = new E_GridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + JTable corner = table.getCornerTableHeader(); + + // PROPERTY: the corner labels the frozen columns, left to right, in the same order they are + // displayed. This is the user-visible consequence of the index arithmetic above. + for (int column = 0; column < COLUMNS; column++) + { + assertThat(String.valueOf(corner.getModel().getValueAt(0, column))) + .as("corner cell %d must name frozen column %d", column, column) + .isEqualTo(model.getColumnName(column)); + } + } + + @Test + @DisplayName("with a FormatModel-shaped frozen model the corner names ID and Name") + void cornerHeaderNamesTheFrozenColumnsForAFormatModel() + { + // The shape production actually uses: the frozen model reports the SAME number for its + // column count and for its frozen-column count (see DefaultSheetPanel, which passes + // getFormatModel().getFrozenColumnModel() straight into FrozenColumnTable). + E_LayoutModel.E_FrozenModel model = E_LayoutModel.E_FrozenModel.create(COLUMNS, new ArrayList<>()); + FrozenColumnTable table = new FrozenColumnTable<>(model); + JTable corner = table.getCornerTableHeader(); + + ResourceBundle bundle = ResourceBundle.getBundle(DataManager.SHEET_STRINGS_PATH); + + // PROPERTY (unchanged): frozen column 0 is the ID column and frozen column 1 is the Name + // column, so the corner must read "ID" then "Name" - the strings the sheet bundle defines + // for those two keys, computed here from the bundle rather than from the code under test. + // + // NOTE FOR THE READER: this passes only through a double negation. FormatModel.getColumnName + // adds getNumFrozenColumns() back on, and for a frozen model that number equals its column + // count, so subtracting the column count in the corner and adding it again in the model + // cancels out. The cancellation is what makes the previous test's out-of-domain indices + // harmless in production - and what makes them a trap for any model whose getColumnName + // does not happen to carry the same offset. + assertThat(String.valueOf(corner.getModel().getValueAt(0, 0))).isEqualTo(bundle.getString("id")); + assertThat(String.valueOf(corner.getModel().getValueAt(0, 1))).isEqualTo(bundle.getString("name")); + } + + // ---------------------------------------------------------------- writes + + @Test + @DisplayName("a successful write stores the value and marks the text banks dirty") + void successfulWriteStoresAndDirties() + { + E_GridModel model = new E_GridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + + assertThat(dirty(TextBankData.class)).as("precondition: nothing dirty yet").isFalse(); + + table.setValueAt("Bulbasaur", 2, 1); + + // PROPERTY: the frozen Name column is a view onto the parallel name bank, so editing it + // edits TextBankData. Two things follow, and both must hold: the value has to actually be + // stored (a write that dirties without storing loses the edit), and the data has to be + // recorded as differing from disk (a store that does not dirty loses the edit at exit, + // silently, because the tool never offers to save it). + assertThat(model.get(2, 1)).as("the written value must be stored").isEqualTo("Bulbasaur"); + assertThat(dirty(TextBankData.class)) + .as("a write to the frozen name column must mark TextBankData dirty") + .isTrue(); + } + + @Test + @DisplayName("a write to (r, c) leaves every other cell untouched") + void writeIsLocal() + { + E_GridModel model = new E_GridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + List before = model.snapshot(); + + table.setValueAt("Chikorita", 1, 1); + + // PROPERTY (locality / injectivity of the write): setValueAt(v, r, c) is an update of the + // single coordinate (r, c). Every other coordinate must be a fixed point of it. A write that + // spills sideways is how a name bank ends up labelling the wrong entries. + List after = model.snapshot(); + for (int row = 0; row < ROWS; row++) + { + for (int column = 0; column < COLUMNS; column++) + { + int index = row * COLUMNS + column; + if (row == 1 && column == 1) + continue; + assertThat(after.get(index)) + .as("cell (%d, %d) must be unchanged by a write to (1, 1)", row, column) + .isEqualTo(before.get(index)); + } + } + assertThat(model.get(1, 1)).isEqualTo("Chikorita"); + } + + @Test + @DisplayName("a rejected write does not leave the text banks falsely marked dirty") + void rejectedWriteDoesNotDirty() + { + E_ThrowingGridModel model = new E_ThrowingGridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + + assertThat(dirty(TextBankData.class)).as("precondition: nothing dirty yet").isFalse(); + + try + { + table.setValueAt("rejected", 0, 1); + } + catch (Throwable expectedInHeadless) + { + // The production error path hard-codes JOptionPane.showMessageDialog, which cannot run + // headlessly; see the disabled test below. What is being asserted here is the state + // BEFORE that dialog is reached, which the throw does not disturb. + } + + // PROPERTY: "dirty" means the in-memory data differs from what was loaded. A write the + // underlying data rejected changed nothing, so it must not make that claim - otherwise the + // user is prompted to save a file that has no edits in it, and (worse) learns to dismiss the + // prompt. DefaultTable/FrozenColumnTable order this correctly: markDirty runs only after + // super.setValueAt returns normally. + assertThat(dirty(TextBankData.class)) + .as("a rejected write must not mark the text banks dirty") + .isFalse(); + assertThat(model.get(0, 1)).as("a rejected write must not have stored anything").isEqualTo("cell[0,1]"); + } + + @Test + @Disabled(""" + TESTABILITY DEFECT, not a skipped assertion. + FrozenColumnTable.setValueAt catches the RuntimeException a rejecting model throws and + reports it with a hard-coded JOptionPane.showMessageDialog(this, ...). In a headless JVM + that call itself throws HeadlessException from INSIDE the catch block, so the very + exception the catch exists to contain is replaced by a different one and still escapes. + The property below - that a rejected write is contained and never reaches the caller - + is therefore unassertable as the code stands, in headless CI and equally in any + environment where the dialog cannot be shown. + The fix is not a test-side workaround: the error path needs to delegate to an injectable + error reporter (a field defaulting to the JOptionPane call) so a test can substitute a + recording one. Until then only the pre-dialog state is observable, which is what + rejectedWriteDoesNotDirty() checks.""") + @DisplayName("a rejected write is contained and never reaches the caller") + void rejectedWriteIsContained() + { + E_ThrowingGridModel model = new E_ThrowingGridModel(ROWS, COLUMNS); + FrozenColumnTable table = new FrozenColumnTable<>(model); + + // PROPERTY: setValueAt is called from the EDT during cell editing. An exception which + // escapes it there is swallowed by the EDT's default handler and the user sees nothing at + // all, which is exactly what the catch block exists to prevent. So no throwable of any kind + // may escape this call. + table.setValueAt("rejected", 0, 1); + } + + // ---------------------------------------------------------------- doubles + + /** + * A plain, honest {@link TableModel}: a rectangle of strings which name their own coordinates, + * with names for its columns and no offset arithmetic of any kind. Every index it is asked for + * is recorded, so the domain property above can be checked directly. + */ + static class E_GridModel implements TableModel + { + private final int rows; + private final int columns; + private final String[][] cells; + private final List columnNameQueries = new ArrayList<>(); + + E_GridModel(int rows, int columns) + { + this.rows = rows; + this.columns = columns; + this.cells = new String[rows][columns]; + for (int row = 0; row < rows; row++) + for (int column = 0; column < columns; column++) + cells[row][column] = "cell[" + row + "," + column + "]"; + } + + String get(int row, int column) + { + return cells[row][column]; + } + + List snapshot() + { + List flat = new ArrayList<>(); + for (int row = 0; row < rows; row++) + for (int column = 0; column < columns; column++) + flat.add(cells[row][column]); + return flat; + } + + /** every index this model has been asked to name, in call order */ + List columnNameQueries() + { + return columnNameQueries; + } + + @Override + public int getRowCount() + { + return rows; + } + + @Override + public int getColumnCount() + { + return columns; + } + + @Override + public String getColumnName(int columnIndex) + { + columnNameQueries.add(columnIndex); + if (columnIndex < 0 || columnIndex >= columns) + return "OUT_OF_DOMAIN(" + columnIndex + ")"; + return "FROZEN#" + columnIndex; + } + + @Override + public Class getColumnClass(int columnIndex) + { + return String.class; + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) + { + return true; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + return cells[rowIndex][columnIndex]; + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) + { + cells[rowIndex][columnIndex] = String.valueOf(aValue); + } + + @Override + public void addTableModelListener(TableModelListener l) + { + } + + @Override + public void removeTableModelListener(TableModelListener l) + { + } + } + + /** the same grid, but every write is rejected the way a real format rejects invalid data */ + static class E_ThrowingGridModel extends E_GridModel + { + E_ThrowingGridModel(int rows, int columns) + { + super(rows, columns); + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) + { + throw new IllegalArgumentException("the value \"" + aValue + "\" is not a legal name"); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/MutatingReadModel.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/MutatingReadModel.java new file mode 100644 index 0000000..57d7835 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/MutatingReadModel.java @@ -0,0 +1,39 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; + +import java.util.List; + +/** + * {@link FakeModel} with one thing changed: its read path lengthens the entry it is reading + * from, exactly the way {@code LearnsetsTable.LearnsetsModel.getValueFor} once did. + *
+ *     while (entryIdx >= learnset.size()) { learnset.add(new LearnsetEntry()); }
+ * 
+ * That loop sat in a method reached from {@code getValueAt}, which Swing calls for every + * visible cell on every repaint - so scrolling a sheet permanently appended entries to files + * the user had never touched, and those entries serialised as a real move rather than as the + * terminator, growing again on every reopen. + *

+ * This double exists purely so the suite can prove it catches that shape: a contract which + * cannot fail is not a contract. See the meta-tests in {@code FormatModelContractTest}. + */ +public class MutatingReadModel extends FakeModel +{ + public MutatingReadModel(List data, List textBankData) + { + super(data, textBankData); + } + + @Override + public Object getValueFor(int entryIdx, FakeColumn property) + { + if (property.idx >= 0) + { + FakeEntry entry = getData().get(entryIdx); + while (currentGroup() >= entry.getRepeated().size()) + entry.getRepeated().add(new int[FakeEntry.GROUP_WIDTH]); + } + return super.getValueFor(entryIdx, property); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/TableExportTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/TableExportTest.java new file mode 100644 index 0000000..a52c9b9 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/TableExportTest.java @@ -0,0 +1,332 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.CheckBoxRenderer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code DefaultTable.exportClean} and {@code DefaultTable.exportEditable} are the two projections + * a user can take of a sheet. Both are pure reads, and both are total functions of the + * table, which pins down four things independently of what the code happens to do today: + * + *

    + *
  • Shape. The clean export is a rectangle of exactly + * {@code rowCount x (frozenColumnCount + columnCount)}; the editable export is + * {@code rowCount x columnCount}. Rectangularity is part of it - the consumers index these + * arrays by column, so a short row is an exception at some later, unrelated point.
  • + *
  • Totality. Every cell of the clean export is filled. A null is not "no value", it is + * a column the walk never visited, which is precisely how the frozen ID/Name columns went + * missing when only {@code getColumnModel()} was iterated.
  • + *
  • Alignment. The clean export contains the editable export as a sub-block starting at + * column {@code frozenColumnCount}. Where a column has no renderer of its own, the two must + * agree cell for cell under that shift, since both then read the same model value.
  • + *
  • Purity. Exporting reads; it must leave the model bit-for-bit identical. An export + * which grows a list while walking it is the same defect class as a paint which does.
  • + *
+ */ +public class TableExportTest +{ + /** the same awkward mix used for the text-source properties, so exports are exercised over + * rendered columns (combo, custom) and un-rendered ones (integer, string) alike */ + private static final CellTypes[] LAYOUT = { + CellTypes.CHECKBOX, // 0 - CheckBoxRenderer: exportClean falls back to the raw value + CellTypes.COMBO_BOX, // 1 - rendered + CellTypes.INTEGER, // 2 - no renderer: exportClean falls back to the raw value + CellTypes.COLORED_COMBO_BOX, // 3 - rendered + CellTypes.CUSTOM, // 4 - rendered + CellTypes.STRING, // 5 - no renderer: exportClean falls back to the raw value + CellTypes.COMBO_BOX, // 6 - rendered + CellTypes.BITFIELD_COMBO_BOX, // 7 - rendered + CellTypes.CUSTOM, // 8 - rendered (shares column 4's renderer) + CellTypes.COMBO_BOX // 9 - rendered + }; + + /** the columns for which exportClean is defined to fall back to {@code String.valueOf(getValueAt(..))}: + * those with no renderer at all, and those whose renderer is a CheckBoxRenderer */ + private static final int[] FALLBACK_COLUMNS = {0, 2, 5}; + + private static final int FROZEN = 2; + private static final int ROWS = 5; + + private E_CustomCellSupplier supplier; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @BeforeEach + void resetStatics() + { + E_ProbeTable.nullPosition = -1; + supplier = new E_CustomCellSupplier(); + } + + private E_ProbeTable build(CellTypes[] layout, int frozen, int rows) + { + E_ProbeTable.sourceCount = sourceCountFor(layout); + E_LayoutModel model = E_LayoutModel.create(layout, frozen, rows); + int[] widths = new int[frozen + layout.length]; + Arrays.fill(widths, 50); + return new E_ProbeTable(model, widths, supplier); + } + + /** one element per combo-box column plus one shared triple for the custom columns */ + private static int sourceCountFor(CellTypes[] layout) + { + int total = 0; + boolean customClaimed = false; + for (CellTypes type : layout) + { + if (type == CellTypes.COMBO_BOX || type == CellTypes.COLORED_COMBO_BOX || type == CellTypes.BITFIELD_COMBO_BOX) + total += 1; + else if (type == CellTypes.CUSTOM && !customClaimed) + { + customClaimed = true; + total += 3; + } + } + return total; + } + + /** every model cell, plus every entry payload, as one comparable snapshot */ + private static List modelSnapshot(E_LayoutModel model) + { + List snapshot = new ArrayList<>(); + snapshot.add("rows=" + model.getRowCount()); + snapshot.add("cols=" + model.getColumnCount()); + for (int row = 0; row < model.getRowCount(); row++) + { + for (int column = 0; column < model.getColumnCount(); column++) + snapshot.add(row + "," + column + "=" + model.getValueAt(row, column)); + snapshot.add("payload" + row + "=" + Arrays.toString(model.getData().get(row).snapshot())); + } + return snapshot; + } + + // ---------------------------------------------------------------- shape + + @Test + @DisplayName("exportClean is a rowCount x (frozenColumnCount + columnCount) rectangle") + void cleanExportHasTheDeclaredShape() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + String[][] clean = table.exportClean(); + + // PROPERTY: the clean export is the whole visible sheet, which is the frozen block placed + // to the left of the editable block. Its width is therefore the sum of the two widths, and + // its height the number of rows - both counted here from the model, never from the result. + assertThat(clean.length).as("one row per model row").isEqualTo(ROWS); + for (int row = 0; row < clean.length; row++) + { + assertThat(clean[row]) + .as("row %d must be as wide as the frozen block plus the editable block", row) + .hasSize(FROZEN + LAYOUT.length); + } + } + + @Test + @DisplayName("exportEditable is a rowCount x columnCount rectangle") + void editableExportHasTheDeclaredShape() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + String[][] editable = table.exportEditable(); + + // PROPERTY: the editable export is exactly the editable columns - the frozen block is not + // part of it, so its width is the table's column count and nothing else. + assertThat(editable.length).isEqualTo(ROWS); + for (int row = 0; row < editable.length; row++) + { + assertThat(editable[row]) + .as("row %d of the editable export must be as wide as the table", row) + .hasSize(LAYOUT.length); + } + } + + // ---------------------------------------------------------------- totality + + @Test + @DisplayName("exportClean has no holes: every cell is filled") + void cleanExportHasNoHoles() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + String[][] clean = table.exportClean(); + + // PROPERTY (totality): the export is defined for every coordinate of the rectangle it + // allocates. A null is not a value the sheet can contain - it is the signature of a column + // (or a whole block) the walk failed to visit, and it becomes a "null" in the user's file + // or an NPE in whatever consumes the export. + for (int row = 0; row < clean.length; row++) + { + for (int column = 0; column < clean[row].length; column++) + { + assertThat(clean[row][column]) + .as("exportClean[%d][%d] was never written", row, column) + .isNotNull(); + } + } + } + + @Test + @DisplayName("exportClean carries the frozen model's own values in columns [0, frozenColumnCount)") + void cleanExportContainsTheFrozenBlock() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + String[][] clean = table.exportClean(); + + // PROPERTY: the frozen ID/Name columns live in a separate model which the table's own + // column model knows nothing about. They are part of the sheet the user sees, so they are + // part of the sheet the user exports; the only way they can appear is for the export to + // read the frozen model directly. Their values are a known function of the coordinate here, + // so their presence, their order and their placement are all checkable at once. + for (int row = 0; row < ROWS; row++) + { + for (int column = 0; column < FROZEN; column++) + { + assertThat(clean[row][column]) + .as("frozen cell (%d, %d) must appear at clean export column %d", row, column, column) + .isEqualTo(E_LayoutModel.E_FrozenModel.frozenValue(row, column)); + } + } + } + + // ---------------------------------------------------------------- alignment + + @Test + @DisplayName("the editable block sits at offset frozenColumnCount inside exportClean") + void editableBlockIsOffsetByTheFrozenWidth() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + String[][] clean = table.exportClean(); + String[][] editable = table.exportEditable(); + + // PROPERTY (alignment): both exports read the same table, so editable column c is clean + // column c + frozenColumnCount. For columns which have no renderer of their own (or a + // checkbox renderer) both projections reduce to String.valueOf(getValueAt(row, c)), so on + // those columns the two arrays must agree cell for cell under exactly that shift. Any other + // offset would mean a user's two exports of the same sheet disagree about which column is + // which. + for (int column : FALLBACK_COLUMNS) + { + for (int row = 0; row < ROWS; row++) + { + assertThat(clean[row][FROZEN + column]) + .as("clean[%d][%d] must be editable[%d][%d]", row, FROZEN + column, row, column) + .isEqualTo(editable[row][column]); + } + } + + // and the fallback really is the raw model value, so the property above is not vacuous + assertThat(table.getColumnModel().getColumn(2).getCellRenderer()) + .as("an INTEGER column is expected to carry no renderer of its own") + .isNull(); + assertThat(table.getColumnModel().getColumn(0).getCellRenderer()) + .as("a CHECKBOX column is expected to carry a CheckBoxRenderer") + .isInstanceOf(CheckBoxRenderer.class); + for (int row = 0; row < ROWS; row++) + { + assertThat(editable[row][2]).isEqualTo(String.valueOf(table.getModel().getValueAt(row, 2))); + } + } + + @Test + @DisplayName("with no frozen columns the clean export is exactly as wide as the table") + void noFrozenColumnsMeansNoOffset() + { + E_ProbeTable table = build(LAYOUT, 0, ROWS); + String[][] clean = table.exportClean(); + String[][] editable = table.exportEditable(); + + // PROPERTY: frozenColumnCount = 0 is the identity case of the alignment property - the + // offset vanishes and the clean export degenerates to the same width as the editable one. + // A table with no frozen model must not reserve, or skip, phantom columns. + assertThat(clean.length).isEqualTo(ROWS); + for (String[] row : clean) + assertThat(row).hasSize(LAYOUT.length); + + for (int column : FALLBACK_COLUMNS) + { + for (int row = 0; row < ROWS; row++) + assertThat(clean[row][column]).isEqualTo(editable[row][column]); + } + } + + // ---------------------------------------------------------------- purity + + @Test + @DisplayName("exporting does not mutate the model") + void exportingIsPure() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + E_LayoutModel model = (E_LayoutModel) table.getFormatModel(); + + List before = modelSnapshot(model); + table.exportClean(); + table.exportEditable(); + table.exportClean(); + List after = modelSnapshot(model); + + // PROPERTY (read purity): an export is an observation. Observations compose - taking two of + // them, in either order, must leave the observed object in the state it started in. This is + // the same invariant a paint has to satisfy, and it is violated by exactly the same mistake: + // a read path which lengthens a list to have something to return. + assertThat(after) + .as("the model must be bit-for-bit unchanged by exporting") + .isEqualTo(before); + } + + @Test + @DisplayName("exporting twice yields equal arrays") + void exportingIsDeterministic() + { + E_ProbeTable table = build(LAYOUT, FROZEN, ROWS); + + String[][] first = table.exportClean(); + String[][] second = table.exportClean(); + + // PROPERTY (referential transparency): if the export is pure and the model is unchanged + // between the two calls, the two results are equal. A difference would mean the export + // depends on hidden state carried over from the previous call - the shared renderers, say. + assertThat(second).isDeepEqualTo(first); + } + + // ---------------------------------------------------------------- boundaries + + @Test + @DisplayName("a table with no rows exports an empty clean array, not an exception") + void zeroRowsCleanExport() + { + E_ProbeTable table = build(LAYOUT, FROZEN, 0); + + // PROPERTY (totality at the boundary): "no rows" is a legal sheet state, and the export of + // an empty sheet is the empty table. A total function must be defined there too. + String[][] clean = table.exportClean(); + assertThat(clean).as("the clean export of an empty sheet is the empty array").isEmpty(); + } + + @Test + @DisplayName("a table with no rows exports an empty editable array, not an exception") + void zeroRowsEditableExport() + { + E_ProbeTable table = build(LAYOUT, FROZEN, 0); + + // PROPERTY (totality at the boundary): identical to the clean case. Note that + // exportEditable bounds its column loop with `output[0].length` - the width of the FIRST + // ROW - rather than with the column count it just allocated from. When there are no rows + // there is no first row, so the bound itself is what fails. The width of a rectangle is not + // a property of any one of its rows; deriving it from row 0 makes a total function partial + // at exactly the boundary where a user is most likely to meet it (a freshly emptied sheet). + String[][] editable = table.exportEditable(); + assertThat(editable).as("the editable export of an empty sheet is the empty array").isEmpty(); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/TextSourceSymmetryTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/TextSourceSymmetryTest.java new file mode 100644 index 0000000..ee6fde9 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/TextSourceSymmetryTest.java @@ -0,0 +1,485 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.ComboBoxCellEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.IndexedStringCellRenderer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The text sources a sheet needs arrive as a positional queue: element k is the name list + * intended for the k-th text-consuming column, in column order. A positional protocol of that kind + * is a statement about a function + * + *
    pos : {columns needing text} -> {0 .. n-1}
+ * + * and it is only well defined if that function is + *
    + *
  • total and injective over the supply - every element is consumed by exactly one + * column, so no column is served an element intended for another; and
  • + *
  • the same function for every walker - the code which installs the editors and the + * code which later refreshes them must derive the identical mapping, otherwise a refresh + * silently re-labels columns.
  • + *
+ * + * Those two clauses are what these tests assert. The reference mapping used here is derived in + * {@link #referencePositions(CellTypes[])} from the specification of the protocol (one + * element per combo-box column; one shared triple for the custom columns, which is what a single + * shared custom editor means) and never from observing the production walk. The independent + * confirmation that this is the intended protocol is {@code EvolutionsTable.obtainTextSources}, + * which supplies exactly {@code referenceSourceCount} elements for its own layout. + *

+ * The historical failure this guards against: one walker consumed three elements for a + * {@code CellTypes.CUSTOM} column while the other consumed none, so every combo-box column after + * the custom column was handed the previous column's names - wrong Pokemon/move/item names in the + * sheet, with nothing thrown and nothing logged. + */ +public class TextSourceSymmetryTest +{ + /** + * A deliberately awkward layout: combo boxes before and after a custom column which sits in + * the middle, all three combo-box flavours present, a second custom column that must + * share with the first, and checkbox/integer/string columns interleaved to prove that + * non-consuming columns do not perturb the mapping. + */ + private static final CellTypes[] AWKWARD = { + CellTypes.CHECKBOX, // 0 - consumes nothing + CellTypes.COMBO_BOX, // 1 - consumes 1 + CellTypes.INTEGER, // 2 - consumes nothing + CellTypes.COLORED_COMBO_BOX, // 3 - consumes 1 + CellTypes.CUSTOM, // 4 - consumes 3 (species, item, move) + CellTypes.STRING, // 5 - consumes nothing + CellTypes.COMBO_BOX, // 6 - consumes 1 + CellTypes.BITFIELD_COMBO_BOX, // 7 - consumes 1 + CellTypes.CUSTOM, // 8 - shares column 4's editor, consumes nothing + CellTypes.COMBO_BOX // 9 - consumes 1 + }; + + private static final int FROZEN = 2; + private static final int ROWS = 4; + + private E_CustomCellSupplier supplier; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @BeforeEach + void resetStatics() + { + E_ProbeTable.sourceCount = 0; + E_ProbeTable.nullPosition = -1; + supplier = new E_CustomCellSupplier(); + } + + // ---------------------------------------------------------------- reference mapping + + /** + * The specification of the positional queue, restated independently of the production code: + * walking the columns in ascending order, a combo-box column of any flavour claims the next + * single element, and the custom columns collectively claim one triple (they share a single + * editor/renderer instance, so there is exactly one triple for the whole table however many + * custom columns there are). + * + * @return column index -> the queue position at which that column's block starts + */ + private static Map referencePositions(CellTypes[] layout) + { + Map positions = new LinkedHashMap<>(); + int next = 0; + boolean customClaimed = false; + for (int column = 0; column < layout.length; column++) + { + CellTypes type = layout[column]; + if (type == CellTypes.COMBO_BOX || type == CellTypes.COLORED_COMBO_BOX || type == CellTypes.BITFIELD_COMBO_BOX) + { + positions.put(column, next); + next += 1; + } + else if (type == CellTypes.CUSTOM && !customClaimed) + { + customClaimed = true; + positions.put(column, next); + next += 3; + } + } + return positions; + } + + /** the total supply the reference mapping accounts for */ + private static int referenceSourceCount(CellTypes[] layout) + { + int total = 0; + boolean customClaimed = false; + for (CellTypes type : layout) + { + if (type == CellTypes.COMBO_BOX || type == CellTypes.COLORED_COMBO_BOX || type == CellTypes.BITFIELD_COMBO_BOX) + total += 1; + else if (type == CellTypes.CUSTOM && !customClaimed) + { + customClaimed = true; + total += 3; + } + } + return total; + } + + // ---------------------------------------------------------------- fixture helpers + + private E_ProbeTable build(CellTypes[] layout, int supply) + { + E_ProbeTable.sourceCount = supply; + E_LayoutModel model = E_LayoutModel.create(layout, FROZEN, ROWS); + int[] widths = new int[FROZEN + layout.length]; + Arrays.fill(widths, 50); + return new E_ProbeTable(model, widths, supplier); + } + + private E_ProbeTable build(CellTypes[] layout) + { + return build(layout, referenceSourceCount(layout)); + } + + /** + * The name list a combo-box column's renderer is currently holding. Read reflectively because + * {@code IndexedStringCellRenderer.items} is package private to the renderers package; reading + * the array itself (rather than a rendered string) is what lets the whole list, and not just + * one entry of it, be compared. + */ + private static String[] rendererItems(TableCellRenderer renderer) + { + try + { + Field field = IndexedStringCellRenderer.class.getDeclaredField("items"); + field.setAccessible(true); + return (String[]) field.get(renderer); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("could not read the renderer's name list", e); + } + } + + /** the name list a combo-box column's editor is currently offering, read off its combo model */ + private static String[] editorItems(TableCellEditor editor) + { + try + { + Field field = ComboBoxCellEditor.class.getDeclaredField("comboBox"); + field.setAccessible(true); + JComboBox box = (JComboBox) field.get(editor); + String[] items = new String[box.getItemCount()]; + for (int i = 0; i < items.length; i++) + items[i] = String.valueOf(box.getItemAt(i)); + return items; + } + catch (ReflectiveOperationException e) + { + throw new AssertionError("could not read the editor's name list", e); + } + } + + /** a snapshot of what every text-consuming column currently holds, keyed by column */ + private static Map installedText(E_ProbeTable table, CellTypes[] layout) + { + Map installed = new LinkedHashMap<>(); + for (int column = 0; column < layout.length; column++) + { + CellTypes type = layout[column]; + if (type == CellTypes.COMBO_BOX || type == CellTypes.COLORED_COMBO_BOX || type == CellTypes.BITFIELD_COMBO_BOX) + installed.put(column, rendererItems(table.getColumnModel().getColumn(column).getCellRenderer())); + } + return installed; + } + + // ---------------------------------------------------------------- properties + + @Test + @DisplayName("every combo-box column holds the array at the queue position the reference mapping assigns it") + void columnsHoldTheArrayTheirQueuePositionNames() + { + E_ProbeTable table = build(AWKWARD); + Map reference = referencePositions(AWKWARD); + + for (Map.Entry entry : reference.entrySet()) + { + int column = entry.getKey(); + int position = entry.getValue(); + if (AWKWARD[column] == CellTypes.CUSTOM) + continue; + + // PROPERTY (positional protocol): the queue is consumed in column order, so the array + // installed in column c must be the one supplied at position pos(c). The arrays are + // self-identifying, so any other array here is proof of an off-by-N in the walk. + assertThat(rendererItems(table.getColumnModel().getColumn(column).getCellRenderer())) + .as("renderer of column %d must hold queue element %d", column, position) + .containsExactly(E_ProbeTable.source(position)); + + // PROPERTY (editor/renderer agreement): the editor offers the user a list of names and + // the renderer turns the chosen index back into a name. If they disagree, choosing an + // entry displays a different one, so both must be served the same queue position. + assertThat(editorItems(table.getColumnModel().getColumn(column).getCellEditor())) + .as("editor of column %d must offer queue element %d", column, position) + .containsExactly(E_ProbeTable.source(position)); + } + } + + @Test + @DisplayName("every custom column shares one editor and one renderer instance") + void customColumnsShareTheirComponents() + { + // Sharing is asserted by identity, not by counting queue draws. The two are different + // claims: a second custom column could be given its own components built from the same + // text, consuming nothing extra and passing every consumption test, while the user gets + // two independent widgets where the sheet means one. Column 8 shares column 4's pair. + int first = -1; + int second = -1; + for (int i = 0; i < AWKWARD.length; i++) + { + if (AWKWARD[i] != CellTypes.CUSTOM) + continue; + if (first < 0) + first = i; + else { + second = i; + break; + } + } + assertThat(first).as("the fixture must contain a custom column").isGreaterThanOrEqualTo(0); + assertThat(second).as("the fixture must contain a second custom column").isGreaterThan(first); + + E_ProbeTable table = build(AWKWARD); + assertThat(table.getColumnModel().getColumn(second).getCellEditor()) + .as("custom columns %d and %d must share one editor", first, second) + .isSameAs(table.getColumnModel().getColumn(first).getCellEditor()); + assertThat(table.getColumnModel().getColumn(second).getCellRenderer()) + .as("custom columns %d and %d must share one renderer", first, second) + .isSameAs(table.getColumnModel().getColumn(first).getCellRenderer()); + } + + @Test + @DisplayName("the custom column is handed the triple starting at its reference position, in order") + void customColumnReceivesItsTripleInOrder() + { + E_ProbeTable table = build(AWKWARD); + int position = referencePositions(AWKWARD).get(4); + + // PROPERTY: a custom column claims three consecutive positions (species, item, move) and + // must receive them in that order - the supplier's three parameters are not interchangeable, + // so a rotation of them is as wrong as reading the wrong block entirely. + assertThat(supplier.rendererCalls()).hasSize(1); + assertThat(supplier.rendererCalls().get(0).length).isEqualTo(3); + for (int slot = 0; slot < 3; slot++) + { + assertThat(supplier.rendererCalls().get(0)[slot]) + .as("custom renderer argument %d must be queue element %d", slot, position + slot) + .containsExactly(E_ProbeTable.source(position + slot)); + assertThat(supplier.editorCalls().get(0)[slot]) + .as("custom editor argument %d must be queue element %d", slot, position + slot) + .containsExactly(E_ProbeTable.source(position + slot)); + } + + // and the installed renderer really is the one built from that triple + assertThat(table.getColumnModel().getColumn(4).getCellRenderer()) + .isInstanceOf(E_CustomCellSupplier.E_CustomRenderer.class); + } + + @Test + @DisplayName("two custom columns share one editor and one renderer, and claim only one triple") + void customColumnsShareOneInstanceAndOneTriple() + { + E_ProbeTable table = build(AWKWARD); + + // PROPERTY: the supplier is consulted once per table, not once per custom column - that is + // what the `customEditor == null` guard means. Consulting it twice would also draw a second + // triple out of the queue and shift every later column's names by three. + assertThat(supplier.editorCalls()).as("the custom supplier must be consulted exactly once").hasSize(1); + assertThat(supplier.rendererCalls()).as("the custom supplier must be consulted exactly once").hasSize(1); + + assertThat(table.getColumnModel().getColumn(8).getCellEditor()) + .as("both custom columns must share one editor instance") + .isSameAs(table.getColumnModel().getColumn(4).getCellEditor()); + assertThat(table.getColumnModel().getColumn(8).getCellRenderer()) + .as("both custom columns must share one renderer instance") + .isSameAs(table.getColumnModel().getColumn(4).getCellRenderer()); + } + + @Test + @DisplayName("the supplied queue is consumed exactly once over, with nothing left and nothing over-drawn") + void queueIsConsumedExactlyOnce() + { + E_ProbeTable table = build(AWKWARD); + int expected = referenceSourceCount(AWKWARD); + + // PROPERTY (totality): every element the concrete table supplies is claimed by exactly one + // column. An element left over means some column read a neighbour's names; an extra removal + // means the walk ran past the end of the supply. + assertThat(table.lastQueue().removals()) + .as("the walk must draw exactly %d elements for this layout", expected) + .isEqualTo(expected); + assertThat(table.lastQueue()) + .as("no supplied name list may go unclaimed") + .isEmpty(); + } + + @Test + @DisplayName("resetIndexedCellRendererText is idempotent: every column keeps the array it already had") + void resetIsIdempotent() + { + E_ProbeTable table = build(AWKWARD); + Map before = installedText(table, AWKWARD); + + table.resetIndexedCellRendererText(); + Map after = installedText(table, AWKWARD); + + // PROPERTY (agreement of walkers): the text banks are unchanged between the two walks, so + // the mapping from queue position to column must be the same function both times. This is + // the direct expression of the desync bug: if the two walkers disagree at any column, the + // refresh silently re-labels that column and every one after it. + assertThat(after.keySet()).isEqualTo(before.keySet()); + for (int column : before.keySet()) + { + assertThat(after.get(column)) + .as("column %d must hold the same name list after a reset as before it", column) + .containsExactly(before.get(column)); + } + } + + @Test + @DisplayName("both walks draw the same number of elements from the queue") + void bothWalksConsumeTheSameAmount() + { + E_ProbeTable table = build(AWKWARD); + table.resetIndexedCellRendererText(); + + assertThat(table.issuedQueues()).hasSize(2); + + // PROPERTY: the consumption count is a function of the column layout alone. Since the + // layout does not change between the two walks, the two counts must be equal - a walker + // which skips the custom column's triple (or claims one it should share) shows up here as + // a difference of exactly three. + assertThat(table.issuedQueues().get(1).removals()) + .as("the refresh walk must consume exactly as much as the install walk") + .isEqualTo(table.issuedQueues().get(0).removals()); + assertThat(table.issuedQueues().get(1)) + .as("the refresh walk must leave nothing unclaimed either") + .isEmpty(); + } + + @Test + @DisplayName("repeated resets are a fixed point") + void repeatedResetsAreAFixedPoint() + { + E_ProbeTable table = build(AWKWARD); + Map reference = installedText(table, AWKWARD); + + for (int iteration = 1; iteration <= 3; iteration++) + { + table.resetIndexedCellRendererText(); + Map current = installedText(table, AWKWARD); + for (int column : reference.keySet()) + { + // PROPERTY (idempotence, f(f(x)) = f(x)): refreshing text from unchanged banks is + // an idempotent operation. If it were not, the sheet's labels would drift further + // every time the user edited a text bank. + assertThat(current.get(column)) + .as("column %d after reset #%d", column, iteration) + .containsExactly(reference.get(column)); + } + } + } + + @Test + @DisplayName("a reset does not disturb the custom columns") + void resetLeavesCustomColumnsAlone() + { + E_ProbeTable table = build(AWKWARD); + TableCellRenderer customRendererBefore = table.getColumnModel().getColumn(4).getCellRenderer(); + TableCellEditor customEditorBefore = table.getColumnModel().getColumn(4).getCellEditor(); + + table.resetIndexedCellRendererText(); + + // PROPERTY (locality): a refresh of indexed text touches only the columns whose text it + // refreshes. Silently replacing the shared custom instance would break the sharing the + // previous property established. + assertThat(table.getColumnModel().getColumn(4).getCellRenderer()).isSameAs(customRendererBefore); + assertThat(table.getColumnModel().getColumn(4).getCellEditor()).isSameAs(customEditorBefore); + assertThat(table.getColumnModel().getColumn(8).getCellRenderer()).isSameAs(customRendererBefore); + } + + @Test + @DisplayName("a layout with no custom column still maps every combo column to its own position") + void layoutWithoutCustomColumnStillAgrees() + { + CellTypes[] plain = { + CellTypes.COMBO_BOX, CellTypes.INTEGER, CellTypes.COMBO_BOX, + CellTypes.CHECKBOX, CellTypes.BITFIELD_COMBO_BOX + }; + E_ProbeTable table = build(plain); + Map reference = referencePositions(plain); + + // PROPERTY: the mapping is defined by the column types alone, so removing the custom column + // from the layout must simply remove its block from the numbering - nothing else moves. + for (Map.Entry entry : reference.entrySet()) + { + assertThat(rendererItems(table.getColumnModel().getColumn(entry.getKey()).getCellRenderer())) + .as("column %d must hold queue element %d", entry.getKey(), entry.getValue()) + .containsExactly(E_ProbeTable.source(entry.getValue())); + } + assertThat(table.lastQueue()).isEmpty(); + } + + @Test + @DisplayName("a null element in the queue degrades to the documented empty fallback, never to null") + void nullElementFallsBackToTheDocumentedEmptyList() + { + int position = referencePositions(AWKWARD).get(1); + E_ProbeTable.nullPosition = position; + E_ProbeTable table = build(AWKWARD); + + // PROPERTY (no silent nulls): a name list is dereferenced on every paint, so the absence of + // a source has to become a well-formed empty list at the point of installation rather than + // a null which only fails later, on the painting thread, with no indication of the cause. + // `getTextFromSource` documents `new String[]{""}` as that fallback. + String[] installed = rendererItems(table.getColumnModel().getColumn(1).getCellRenderer()); + assertThat(installed).as("a missing name list must not be installed as null").isNotNull(); + assertThat(installed).as("the documented fallback for a missing name list").containsExactly(""); + + String[] offered = editorItems(table.getColumnModel().getColumn(1).getCellEditor()); + assertThat(offered).as("the editor must receive the same fallback").containsExactly(""); + } + + @Test + @DisplayName("a queue shorter than the layout needs must fail with a diagnosable message") + void shortQueueFailsDiagnosably() + { + int needed = referenceSourceCount(AWKWARD); + + // PROPERTY (diagnosability): a supply/demand mismatch is a programming error in a concrete + // table, and the only way it is ever noticed is the message it produces. A throw carrying no + // message tells a maintainer neither which table nor which column ran out, so the failure is + // indistinguishable from any other empty-collection bug in the process. + assertThatThrownBy(() -> build(AWKWARD, needed - 1)) + .as("running out of text sources must say so") + .isInstanceOf(RuntimeException.class) + .satisfies(thrown -> assertThat(thrown.getMessage()) + .as("the failure must carry a message naming the exhausted text-source queue") + .isNotNull() + .isNotBlank()); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/ValueRangeEnforcementTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/ValueRangeEnforcementTest.java new file mode 100644 index 0000000..33da420 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/ValueRangeEnforcementTest.java @@ -0,0 +1,251 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellTypes; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A column must refuse a value it cannot store. + *

+ * The property is that {@code getCellValueRange} is a promise, not a hint: if a column says + * it holds 0..511, then every value in that range must be storable and every value outside + * it must be refused. A range that is merely advisory is worse than none, because the write + * path narrows silently - a move of 512 was written back as move 0, which reads as "learns + * nothing", and the user's only clue was the wrong data on reload. + *

+ * These tests drive {@link FormatModel#prepareObjectForWriting(Object, CellTypes, int[])} + * directly, because that is the one point every write funnels through: a cell editor, a + * paste, and a programmatic write all reach it, whereas the editor-side check reached only + * values typed into an INTEGER cell. + */ +class ValueRangeEnforcementTest +{ + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + /** the smallest thing that can answer prepareObjectForWriting; no Core types involved */ + private static final FormatModel MODEL = new E_RangeModel(); + + private static Object prepare(Object value, CellTypes type, int[] range) + { + return MODEL.prepareObjectForWriting(value, type, range); + } + + @Nested + @DisplayName("numeric columns") + class Numeric + { + @Test + void everyValueInsideTheDeclaredRangeIsAccepted() + { + // the range is a promise about what can be stored. if a value inside it is + // refused, the sheet is offering the user something it cannot deliver + int[] range = {0, 511}; + for (int i = range[0]; i <= range[1]; i++) + assertThat(prepare(i, CellTypes.INTEGER, range)).isEqualTo(i); + } + + @Test + void theBoundsThemselvesAreAccepted() + { + // inclusive means inclusive; an exclusive comparison shows up here first + assertThatCode(() -> { + prepare(0, CellTypes.INTEGER, new int[] {0, 511}); + prepare(511, CellTypes.INTEGER, new int[] {0, 511}); + prepare(-128, CellTypes.INTEGER, new int[] {-128, 127}); + prepare(127, CellTypes.INTEGER, new int[] {-128, 127}); + }).doesNotThrowAnyException(); + } + + @Test + void oneStepOutsideEitherBoundIsRefused() + { + int[] range = {0, 511}; + assertThatThrownBy(() -> prepare(512, CellTypes.INTEGER, range)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> prepare(-1, CellTypes.INTEGER, range)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aSignedColumnRefusesTheUnsignedValueThatWouldFit() + { + // move priority is a signed byte: 200 fits in eight bits but is not a legal + // priority, and writing it stores -56. the range is what distinguishes them + assertThatThrownBy(() -> prepare(200, CellTypes.INTEGER, new int[] {-128, 127})) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theRefusalNamesTheValueAndTheRange() + { + // a message missing either one cannot be acted on + assertThatThrownBy(() -> prepare(512, CellTypes.INTEGER, new int[] {0, 511})) + .hasMessageContaining("512") + .hasMessageContaining("511"); + } + + @Test + void aStringIsCheckedAgainstTheRangeToo() + { + // a paste arrives as text, and the paste path is precisely the one that had no + // check at all - converting without then checking would fix nothing + assertThatThrownBy(() -> prepare("512", CellTypes.INTEGER, new int[] {0, 511})) + .isInstanceOf(IllegalArgumentException.class); + assertThat(prepare("511", CellTypes.INTEGER, new int[] {0, 511})).isEqualTo(511); + } + + @Test + void combinationColumnsAreCheckedNotJustIntegerOnes() + { + // COMBO_BOX and BITFIELD columns never had an editor-side range check, so they + // are the ones this change actually protects + for (CellTypes type : new CellTypes[] {CellTypes.COMBO_BOX, CellTypes.COLORED_COMBO_BOX, + CellTypes.BITFIELD_COMBO_BOX, CellTypes.INTEGER}) + { + assertThatThrownBy(() -> prepare(999, type, new int[] {0, 18})) + .as("%s must honour its declared range", type) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void aNullRangeSkipsTheCheckRatherThanFailing() + { + // columns where no numeric range applies must still be writable + assertThatCode(() -> prepare(99999, CellTypes.INTEGER, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("non-numeric text in a numeric column") + class NonNumeric + { + @Test + void aNameIsRefusedWithAMessageThatExplainsWhatTheColumnWants() + { + // the sheet exports rendered text, so an exported column is full of names. pasting + // it back in hits every combo box cell, and parseInt's own message ("For input + // string") does not tell the user what to do about it + assertThatThrownBy(() -> prepare("Bulbasaur", CellTypes.COMBO_BOX, new int[] {0, 511})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Bulbasaur") + .hasMessageContaining("number"); + } + + @Test + void theOriginalParseFailureIsKeptAsTheCause() + { + // rewriting a message must not destroy the diagnosis underneath it + assertThatThrownBy(() -> prepare("12abc", CellTypes.INTEGER, new int[] {0, 511})) + .hasCauseInstanceOf(NumberFormatException.class); + } + + @Test + void aStringColumnStillTakesText() + { + // names are stored as text and must not be run through the number parser + assertThat(prepare("Bulbasaur", CellTypes.STRING, null)).isEqualTo("Bulbasaur"); + } + } + + @Nested + @DisplayName("checkbox columns") + class Checkbox + { + @Test + void theSpreadsheetSpellingsAreUnderstood() + { + // Boolean.parseBoolean answers false for anything it does not recognise, so a + // pasted column of 1s and 0s silently cleared every box it touched. 1/0 is what a + // spreadsheet actually produces, so it has to be understood, not guessed at + assertThat(prepare("1", CellTypes.CHECKBOX, null)).isEqualTo(true); + assertThat(prepare("0", CellTypes.CHECKBOX, null)).isEqualTo(false); + assertThat(prepare("true", CellTypes.CHECKBOX, null)).isEqualTo(true); + assertThat(prepare("false", CellTypes.CHECKBOX, null)).isEqualTo(false); + assertThat(prepare("TRUE", CellTypes.CHECKBOX, null)).isEqualTo(true); + assertThat(prepare("Yes", CellTypes.CHECKBOX, null)).isEqualTo(true); + assertThat(prepare("no", CellTypes.CHECKBOX, null)).isEqualTo(false); + } + + @Test + void surroundingSpaceDoesNotChangeTheAnswer() + { + // a spreadsheet paste routinely carries it + assertThat(prepare(" 1 ", CellTypes.CHECKBOX, null)).isEqualTo(true); + } + + @Test + void somethingThatIsNotAYesOrNoIsRefusedRatherThanReadAsFalse() + { + // this is the whole point: an unrecognised value must not quietly become false, + // because "every checkbox in the column is now clear" is indistinguishable from a + // deliberate edit once it has happened + assertThatThrownBy(() -> prepare("Bulbasaur", CellTypes.CHECKBOX, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Bulbasaur"); + assertThatThrownBy(() -> prepare("2", CellTypes.CHECKBOX, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aBooleanPassesThroughUntouched() + { + assertThat(prepare(true, CellTypes.CHECKBOX, null)).isEqualTo(true); + assertThat(prepare(false, CellTypes.CHECKBOX, null)).isEqualTo(false); + } + } + + /** a FormatModel with no columns; only prepareObjectForWriting is exercised */ + private static class E_RangeModel extends FormatModel + { + E_RangeModel() + { + super(java.util.List.of(), java.util.List.of()); + } + + @Override + public String getColumnNameKey(int columnIndex) + { + return null; + } + + @Override + public int getColumnCount() + { + return 0; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + return null; + } + + @Override + public FormatModel getFrozenColumnModel() + { + return null; + } + + @Override + public Object getValueFor(int rowIdx, CellTypes property) + { + return null; + } + + @Override + public void setValueFor(Object aValue, int rowIdx, CellTypes property) + { + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/BitfieldComboBoxEditorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/BitfieldComboBoxEditorTest.java new file mode 100644 index 0000000..61e7e1f --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/BitfieldComboBoxEditorTest.java @@ -0,0 +1,194 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.BitfieldComboBoxEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.BitfieldStringCellRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +class BitfieldComboBoxEditorTest +{ + /** entry 0 is "no bit set", entry n is bit n-1: 17 entries is the full 16 bit domain. */ + private static final String[] ITEMS = names(17); + private static final int LAST_INDEX = ITEMS.length - 1; + + private final JTable table = table(); + private final BitfieldComboBoxEditor editor = new BitfieldComboBoxEditor(ITEMS); + + /** The encoding itself, written out from its definition rather than taken from the code. */ + private static int bitValueOf(int index) + { + return index == 0 ? 0 : 1 << (index - 1); + } + + private JComboBox comboOpenedOn(Object value) + { + return (JComboBox) editor.getTableCellEditorComponent(table, value, false, 1, 0); + } + + @Test + @DisplayName("picking entry n stores the single bit value 1 << (n-1), and entry 0 stores 0") + void indexMapsToItsOwnBit() + { + // Expectation derived from the definition of the encoding, not from running the editor. + // An off-by-one here sets the neighbouring flag on every move in the sheet. + JComboBox combo = comboOpenedOn(0); + + for (int index = 0; index <= LAST_INDEX; index++) + { + combo.setSelectedIndex(index); + assertThat(editor.getCellEditorValue()).as("value stored by entry %d", index).isEqualTo(bitValueOf(index)); + } + } + + @Test + @DisplayName("distinct entries store distinct values") + void indexToBitIsInjective() + { + // Two entries collapsing onto one stored value means one of the flags can never be set, + // and the sheet shows the wrong one for it. + JComboBox combo = comboOpenedOn(0); + List stored = new ArrayList<>(); + + for (int index = 0; index <= LAST_INDEX; index++) + { + combo.setSelectedIndex(index); + stored.add(editor.getCellEditorValue()); + } + + assertThat(stored).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("opening a cell on 1 << (n-1) preselects entry n, over the whole 16 bit domain") + void bitMapsBackToItsOwnIndex() + { + // The reverse direction of the same encoding. It is computed with a logarithm, so the + // boundaries (entry 0, and the top bit) are where it is most likely to slip by one. + for (int index = 0; index <= LAST_INDEX; index++) + { + JComboBox combo = comboOpenedOn(bitValueOf(index)); + assertThat(combo.getSelectedIndex()).as("entry preselected for stored value %d", bitValueOf(index)).isEqualTo(index); + } + } + + @Test + @DisplayName("index -> value -> index is the identity for every entry") + void indexRoundTripIsIdentity() + { + // Opening a cell, touching nothing and closing it must not change what is stored. + JComboBox combo = comboOpenedOn(0); + + for (int index = 0; index <= LAST_INDEX; index++) + { + combo.setSelectedIndex(index); + Object stored = editor.getCellEditorValue(); + JComboBox reopened = comboOpenedOn(stored); + assertThat(reopened.getSelectedIndex()).as("entry reached again from entry %d via stored value %s", index, stored).isEqualTo(index); + } + } + + @Test + @DisplayName("value -> index -> value is the identity for every single-bit value") + void valueRoundTripIsIdentity() + { + // The other direction: a stored bitfield the user never edits must come back out unchanged. + for (int index = 0; index <= LAST_INDEX; index++) + { + int value = bitValueOf(index); + comboOpenedOn(value); + assertThat(editor.getCellEditorValue()).as("value produced by opening on %d and touching nothing", value).isEqualTo(value); + } + } + + @Test + @DisplayName("what the bitfield renderer displays for a value is the entry the editor stores that value for") + void rendererAndEditorAgreeOnEveryEntry() + { + // The user picks a flag by its name and the sheet must paint that same name back. If the + // two halves round differently, the cell reads as a flag the user never chose. + BitfieldStringCellRenderer renderer = new BitfieldStringCellRenderer(ITEMS); + JComboBox combo = comboOpenedOn(0); + + for (int index = 0; index <= LAST_INDEX; index++) + { + String shownInDropdown = displayedTextAt(combo, index); + + selectByDisplayedText(combo, shownInDropdown); + Object stored = editor.getCellEditorValue(); + + JLabel painted = (JLabel) renderer.getTableCellRendererComponent(table, stored, false, false, 1, 0); + assertThat(painted.getText()) + .as("cell text after picking entry %d ('%s'), which stored %s", index, shownInDropdown, stored) + .isEqualTo(shownInDropdown); + } + } + + @Test + @DisplayName("opening a cell on a bitfield outside the declared entries does not throw") + void openingOnAnUndeclaredBitfieldDoesNotThrow() + { + // A bitfield with several bits set, an undeclared high bit, or a null is reachable from a + // hacked ROM or a paste. Throwing escapes onto the EDT on a double click and leaves a cell + // the user can never repair. + List values = Arrays.asList(null, -1, 0b101, 1 << 20, Integer.MIN_VALUE, Integer.MAX_VALUE, "1", 3.5d); + + List failures = failuresOver(values, + value -> editor.getTableCellEditorComponent(table, value, false, 1, 0)); + + assertThat(failures).as("stored values this editor cannot open a cell on").isEmpty(); + } + + @Test + @DisplayName("an edit that selects nothing leaves the bitfield as it was") + void noSelectionKeepsTheOriginalValue() + { + // Same defect as ComboBoxCellEditor, in the subclass, and it was live: this editor + // overrode getTableCellEditorComponent without calling super, so the value the cell + // arrived with was never recorded and an edit selecting nothing handed back null. Not + // -1 and not the old flag - null, which is the cell losing its contents. Type-to-search + // leaves the selection empty whenever the typed text matches no entry exactly, so a + // mistyped flag name was enough to reach it. + editor.getTableCellEditorComponent(table, 1 << 2, false, 1, 0); + + ((javax.swing.JComboBox) editor.getTableCellEditorComponent(table, 1 << 2, false, 1, 0)) + .setSelectedIndex(-1); + + assertThat(editor.getCellEditorValue()) + .as("nothing selected means the bitfield keeps the flag it had") + .isEqualTo(1 << 2); + } + + @Test + @DisplayName("a bit this column has no name for survives an edit that selects nothing") + void undeclaredBitIsPreserved() + { + // Opening on an undeclared bit deliberately clears the selection - there is no entry to + // show. That must not be read as "the user cleared the flag": closing the editor has to + // leave the odd value alone, or merely looking at a hacked ROM's row rewrites it. + editor.getTableCellEditorComponent(table, 1 << 20, false, 1, 0); + + assertThat(editor.getCellEditorValue()) + .as("an undeclared bit is left alone, not replaced by null or 0") + .isEqualTo(1 << 20); + } + + @Test + @DisplayName("a real selection is still reported as the new bitfield") + void aSelectionIsStillReported() + { + // the control: leaving an unselected edit alone must not swallow a genuine one + javax.swing.JComboBox box = + (javax.swing.JComboBox) editor.getTableCellEditorComponent(table, 0, false, 1, 0); + box.setSelectedIndex(3); + + assertThat(editor.getCellEditorValue()).isEqualTo(1 << 2); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/BitfieldStringCellRendererTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/BitfieldStringCellRendererTest.java new file mode 100644 index 0000000..5b81785 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/BitfieldStringCellRendererTest.java @@ -0,0 +1,93 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.BitfieldStringCellRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.util.ArrayList; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +class BitfieldStringCellRendererTest +{ + /** index 0 means "no bit set"; index n means bit n-1, so 17 entries covers a full 16 bit field. */ + private static final String[] ITEMS = names(17); + + private final JTable table = table(); + private final BitfieldStringCellRenderer renderer = new BitfieldStringCellRenderer(ITEMS); + + @Test + @DisplayName("renders a component for every value a painting table could hand it, without throwing") + void totalOverEveryValue() + { + // Runs inside paint(): one value it cannot survive makes the whole sheet unpaintable. + // The hostile list includes values with several bits set and values with none of the + // declared bits - both reachable from a hacked ROM or a paste. + List values = new ArrayList<>(hostileValues()); + values.add(0b101); + values.add(0xFFFF); + values.add(1 << 20); + + List failures = failuresOver(values, + value -> renderer.getTableCellRendererComponent(table, value, false, false, 1, 0)); + + assertThat(failures).as("values this bitfield renderer cannot render").isEmpty(); + } + + @Test + @DisplayName("displays entry n for the single-bit value 1 << (n-1), across the whole 16 bit field") + void singleBitValueDisplaysItsOwnEntry() + { + // Expectation comes from the definition of the encoding (value == 1 << (index - 1)), + // not from running the renderer. If the shift or the off-by-one drifts, every bitfield + // column in the sheet names the wrong flag. + for (int index = 1; index <= 16; index++) + { + int value = 1 << (index - 1); + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, value, false, false, 1, 0); + assertThat(label.getText()).as("text shown for value %d (bit %d)", value, index - 1).isEqualTo(ITEMS[index]); + } + } + + @Test + @DisplayName("displays the zeroth entry for an empty bitfield") + void zeroDisplaysZerothEntry() + { + // Zero is the boundary of the encoding: it has no bit to take a logarithm of. + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, 0, false, false, 1, 0); + assertThat(label.getText()).isEqualTo(ITEMS[0]); + } + + @Test + @DisplayName("distinct single-bit values never display the same entry") + void distinctBitsDisplayDistinctEntries() + { + // Injectivity of the display mapping: if two different stored bitfields read as the same + // flag name, the user cannot tell them apart and edits blind. + List shown = new ArrayList<>(); + for (int index = 0; index <= 16; index++) + { + int value = index == 0 ? 0 : 1 << (index - 1); + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, value, false, false, 1, 0); + shown.add(label.getText()); + } + assertThat(shown).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("a bit with no declared name never displays some other flag's name") + void undeclaredBitShowsNoName() + { + // Showing a plausible but wrong flag name is how a user comes to believe a move targets + // something it does not. + for (int bit = ITEMS.length - 1; bit < 31; bit++) + { + int value = 1 << bit; + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, value, false, false, 1, 0); + assertThat(label.getText()).as("text shown for undeclared bit %d (value %d)", bit, value).isNotIn((Object[]) ITEMS); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CellTypesTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CellTypesTest.java new file mode 100644 index 0000000..dde3b17 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CellTypesTest.java @@ -0,0 +1,228 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.BitfieldComboBoxEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.CheckBoxEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.ComboBoxCellEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.NumberOnlyCellEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.BitfieldStringCellRenderer; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.CheckBoxRenderer; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.IndexedStringCellRenderer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; +import java.util.EnumSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Every cell type must be dispatched to components that can actually display and edit it. + *

+ * These assertions used to be made by reading the source text of + * {@code DefaultTable.loadCellRenderers} and looking for each constant's name, because the + * mapping could not be reached without constructing a whole sheet model. That test could not + * distinguish a real dispatch from a mention in a comment, and it depended on the process + * working directory. Now that the mapping is a pure function it is simply called. + */ +class CellTypesTest +{ + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + /** + * The one cell type deliberately left to the JTable defaults: a STRING column needs no + * special editor or renderer. Every other constant must be dispatched explicitly. + */ + private static final Set INTENTIONALLY_UNDISPATCHED = EnumSet.of(CellTypes.STRING); + + private static final String[][] NAMES = {{"a", "b", "c"}, {"i0", "i1"}, {"m0", "m1"}}; + + private static final CellTypes.CustomCellFunctionSupplier CUSTOM_SUPPLIER = + new CellTypes.CustomCellFunctionSupplier() + { + @Override + public TableCellEditor getEditor(String[]... strings) + { + return new ComboBoxCellEditor(strings[0]); + } + + @Override + public TableCellRenderer getRenderer(String[]... strings) + { + return new IndexedStringCellRenderer(strings[0]); + } + }; + + /** + * Whether the editor will hand back the given text as the cell's new value. + *

+ * The bound is enforced when the value is read, not by the document filter - the filter + * only strips non-digits, so it accepts "128" quite happily. Asserting through + * getCellEditorValue is what actually exercises the range; it also avoids stopCellEditing, + * which raises a dialog and so cannot run without a display. + */ + private static boolean accepts(NumberOnlyCellEditor editor, String text) + { + javax.swing.JTextField field = (javax.swing.JTextField) editor + .getTableCellEditorComponent(new javax.swing.JTable(), "0", false, 0, 0); + field.setText(text); + return text.equals(editor.getCellEditorValue()); + } + + private static TableCellComponents.Pair dispatch(CellTypes type) + { + return TableCellComponents.forType(type, NAMES, new int[] {0, 255}, CUSTOM_SUPPLIER); + } + + @Test + @DisplayName("every cell type is dispatched to an editor and renderer, or is explicitly a default-rendered type") + void everyCellTypeIsDispatched() + { + // The mapping is a switch over an enum but returns a default rather than being + // exhaustive, so the compiler will not catch a constant added without a branch. Such a + // column silently falls through to the plain JTable editor - a species column that + // accepts free text, a flag column with no checkbox. Enumerating values() means a new + // constant is covered the moment it exists. + for (CellTypes type : CellTypes.values()) + { + TableCellComponents.Pair pair = dispatch(type); + assertThat(pair).as("%s must map to a pair, never null", type).isNotNull(); + + if (INTENTIONALLY_UNDISPATCHED.contains(type)) + continue; + + assertThat(pair.editor()).as("%s must be given an editor", type).isNotNull(); + + // INTEGER is the one dispatched type with no renderer of its own: a number is + // displayed correctly by the table's default renderer, and only its editing needs + // bounding. Every other type shows something other than its stored value. + if (type != CellTypes.INTEGER) + assertThat(pair.renderer()).as("%s must be given a renderer", type).isNotNull(); + } + } + + @Test + @DisplayName("each cell type gets the components that can actually display it") + void eachTypeGetsTheRightComponents() + { + // asserted against what each type means, not against what the code returns: a + // checkbox column needs a checkbox, a column showing names needs the renderer that + // maps an index to a name, and a bitfield needs the one that maps a bit to a name + assertThat(dispatch(CellTypes.CHECKBOX).renderer()).isInstanceOf(CheckBoxRenderer.class); + assertThat(dispatch(CellTypes.CHECKBOX).editor()).isInstanceOf(CheckBoxEditor.class); + + assertThat(dispatch(CellTypes.COMBO_BOX).renderer()).isInstanceOf(IndexedStringCellRenderer.class); + assertThat(dispatch(CellTypes.COMBO_BOX).editor()).isInstanceOf(ComboBoxCellEditor.class); + + assertThat(dispatch(CellTypes.COLORED_COMBO_BOX).renderer()) + .isInstanceOf(IndexedStringCellRenderer.ColoredIndexedStringCellRenderer.class); + + assertThat(dispatch(CellTypes.BITFIELD_COMBO_BOX).renderer()).isInstanceOf(BitfieldStringCellRenderer.class); + assertThat(dispatch(CellTypes.BITFIELD_COMBO_BOX).editor()).isInstanceOf(BitfieldComboBoxEditor.class); + + assertThat(dispatch(CellTypes.INTEGER).editor()).isInstanceOf(NumberOnlyCellEditor.class); + + assertThat(dispatch(CellTypes.STRING).renderer()).isNull(); + assertThat(dispatch(CellTypes.STRING).editor()).isNull(); + } + + @Test + @DisplayName("the numeric editor is bounded by the column's declared range") + void integerEditorHonoursTheDeclaredRange() + { + // the range is the only thing distinguishing a priority column (-128..127) from a + // stat column (0..255); dropping it on the way through would silently widen both + // asserted behaviourally: a signed column must take -128 and refuse -129, and the only + // way to tell the range was carried through is to drive the editor with values that lie + // on either side of it. isNotNull() would pass against a hard-coded 0..255. + NumberOnlyCellEditor signed = (NumberOnlyCellEditor) TableCellComponents + .forType(CellTypes.INTEGER, null, new int[] {-128, 127}, CUSTOM_SUPPLIER).editor(); + assertThat(accepts(signed, "-128")).as("a signed column must accept its lower bound").isTrue(); + assertThat(accepts(signed, "127")).as("a signed column must accept its upper bound").isTrue(); + assertThat(accepts(signed, "128")).as("a signed column must refuse 128").isFalse(); + assertThat(accepts(signed, "-129")).as("a signed column must refuse -129").isFalse(); + + // and an unsigned column must take the values the signed one refuses, or the range was + // ignored in the other direction + NumberOnlyCellEditor unsigned = (NumberOnlyCellEditor) TableCellComponents + .forType(CellTypes.INTEGER, null, new int[] {0, 255}, CUSTOM_SUPPLIER).editor(); + assertThat(accepts(unsigned, "255")).as("an unsigned column must accept 255").isTrue(); + assertThat(accepts(unsigned, "-1")).as("an unsigned column must refuse -1").isFalse(); + } + + @Test + @DisplayName("a missing name list degrades to a placeholder rather than reaching the renderer as null") + void missingTextDoesNotProduceANullBackedRenderer() + { + // a null names array would not fail here but during painting, taking the whole sheet + // down; the documented behaviour is to substitute a single empty entry + for (CellTypes type : new CellTypes[] {CellTypes.COMBO_BOX, CellTypes.COLORED_COMBO_BOX, + CellTypes.BITFIELD_COMBO_BOX}) + { + assertThatCode(() -> { + TableCellComponents.forType(type, null, null, CUSTOM_SUPPLIER); + TableCellComponents.forType(type, new String[][] {null}, null, CUSTOM_SUPPLIER); + TableCellComponents.forType(type, new String[0][], null, CUSTOM_SUPPLIER); + }).as("%s with no names must still build", type).doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("a custom column with no supplier or no text yields no components rather than throwing") + void customColumnDegradesRatherThanThrowing() + { + // buildColumnTextSources assigns the species/item/move triple only to the first + // custom column, so any path that asks a later one for its text gets null. That used + // to reach an array index and fail during table construction. + assertThat(TableCellComponents.forType(CellTypes.CUSTOM, null, null, CUSTOM_SUPPLIER).renderer()).isNull(); + assertThat(TableCellComponents.forType(CellTypes.CUSTOM, NAMES, null, null).editor()).isNull(); + assertThat(TableCellComponents.forType(CellTypes.CUSTOM, new String[][] {{"a"}}, null, CUSTOM_SUPPLIER) + .editor()).isNull(); + } + + @Test + @DisplayName("the dispatch is a pure function - the same input yields equivalent, independent components") + void dispatchIsPureAndReturnsFreshInstances() + { + // sharing is the caller's decision (DefaultTable deliberately shares one pair across + // every custom column); the mapping itself must not hold state between calls, or two + // columns would silently edit through the same widget + for (CellTypes type : CellTypes.values()) + { + TableCellComponents.Pair first = dispatch(type); + TableCellComponents.Pair second = dispatch(type); + + if (first.editor() != null) + assertThat(first.editor()).as("%s editor", type).isNotSameAs(second.editor()); + if (first.renderer() != null) + assertThat(first.renderer()).as("%s renderer", type).isNotSameAs(second.renderer()); + } + } + + @Test + @DisplayName("the custom cell supplier provides both halves") + void customSupplierProvidesBothHalves() + { + TableCellComponents.Pair pair = dispatch(CellTypes.CUSTOM); + assertThat(pair.renderer()).isNotNull(); + assertThat(pair.editor()).isNotNull(); + } + + @Test + @DisplayName("the set of cell types is the known one") + void constantSetIsTheKnownOne() + { + // a tripwire: adding a constant should be a deliberate act that also updates the + // dispatch and this list, rather than something that happens silently + assertThat(CellTypes.values()).containsExactlyInAnyOrder( + CellTypes.STRING, CellTypes.INTEGER, CellTypes.CHECKBOX, CellTypes.COMBO_BOX, + CellTypes.COLORED_COMBO_BOX, CellTypes.BITFIELD_COMBO_BOX, CellTypes.CUSTOM); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CellsTestSupport.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CellsTestSupport.java new file mode 100644 index 0000000..3bf0ac4 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CellsTestSupport.java @@ -0,0 +1,124 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +/** + * Shared fixtures for the cell renderer/editor tests. + *

+ * Deliberately contains no PokEditor-Core types: everything under test here is a plain Swing + * component, so a test failure can only mean the component is wrong. + */ +final class CellsTestSupport +{ + static + { + // must be set before any AWT class initialises a GraphicsEnvironment + System.setProperty("java.awt.headless", "true"); + } + + private CellsTestSupport() {} + + /** A live JTable, because every renderer here reads selection/background state off one. */ + static JTable table() + { + return new JTable(new DefaultTableModel(4, 4)); + } + + /** + * The domain of values a renderer can actually be handed by a painting JTable: legal + * indices, indices past the end of the name list (hacked ROM, or a paste of a raw number), + * negatives, nulls, and values whose type is not what the column nominally holds. + * A renderer runs inside paint() once per visible cell - if any of these throws, the whole + * sheet stops painting and the exception storm is invisible to the user. + */ + static List hostileValues() + { + return Arrays.asList( + 0, 1, 5, 17, 20, 25, 255, 65535, + -1, -20, Integer.MAX_VALUE, Integer.MIN_VALUE, + null, + "0", "5", "20", "-1", + "", " ", "not a number", "12abc", + 3.5d, 7L, Boolean.TRUE, new Object()); + } + + /** Same idea, for components whose column nominally holds a boolean. */ + static List hostileBooleanValues() + { + return Arrays.asList(Boolean.TRUE, Boolean.FALSE, null, "true", "", 0, 1, new Object()); + } + + /** + * Runs {@code call} over every value and reports, rather than propagates, each failure - + * so one test names every value the component cannot survive instead of stopping at the + * first one. + */ + static List failuresOver(List values, Function call) + { + List failures = new ArrayList<>(); + for (Object value : values) + { + try + { + if (call.apply(value) == null) + failures.add(describe(value) + " -> returned null instead of a component"); + } + catch (Throwable t) + { + failures.add(describe(value) + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + return failures; + } + + static String describe(Object value) + { + if (value == null) + return "null"; + return value.getClass().getSimpleName() + "(" + value + ")"; + } + + /** {@code count} distinct display names, so a round trip cannot pass by coincidence. */ + static String[] names(int count) + { + String[] items = new String[count]; + items[0] = "-----"; + for (int i = 1; i < count; i++) + items[i] = "name-" + i; + return items; + } + + /** The text a combo box actually shows for row {@code index} of its model. */ + static String displayedTextAt(JComboBox comboBox, int index) + { + return String.valueOf(comboBox.getItemAt(index)); + } + + /** + * Simulates a user picking the option whose visible text is {@code text}: finds it by what + * it says, not by the index the test already knows. + */ + static int selectByDisplayedText(JComboBox comboBox, String text) + { + int found = -1; + for (int i = 0; i < comboBox.getItemCount(); i++) + { + if (displayedTextAt(comboBox, i).equals(text)) + { + if (found != -1) + throw new IllegalArgumentException("ambiguous fixture: '" + text + "' appears at " + found + " and " + i); + found = i; + } + } + if (found == -1) + throw new IllegalArgumentException("no option displays '" + text + "'"); + comboBox.setSelectedIndex(found); + return found; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CheckBoxEditorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CheckBoxEditorTest.java new file mode 100644 index 0000000..2fdc856 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CheckBoxEditorTest.java @@ -0,0 +1,84 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.CheckBoxEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.CheckBoxRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.*; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.table; +import static org.assertj.core.api.Assertions.assertThat; + +class CheckBoxEditorTest +{ + private final JTable table = table(); + + @Test + @DisplayName("true and false survive the render -> edit -> value round trip unchanged") + void booleanRoundTrip() + { + // What the sheet paints, what the editor opens with, and what the editor hands back must + // all be the same flag. If they disagree, opening a cell and clicking nothing still + // flips the stored value. + for (boolean stored : new boolean[] {true, false}) + { + CheckBoxRenderer renderer = new CheckBoxRenderer(); + CheckBoxEditor editor = new CheckBoxEditor(); + + boolean painted = checkBoxIn(renderer.getTableCellRendererComponent(table, stored, false, false, 1, 0)).isSelected(); + assertThat(painted).as("painted state for %s", stored).isEqualTo(stored); + + editor.getTableCellEditorComponent(table, stored, false, 1, 0); + assertThat(editor.getCellEditorValue()).as("value produced with no user interaction for %s", stored).isEqualTo(stored); + } + } + + @Test + @DisplayName("the value handed back is the state of the checkbox the user actually clicked") + void userTogglePropagates() + { + // Derived from what an editor IS: it must report the widget's state, not the state it + // was opened with. + CheckBoxEditor editor = new CheckBoxEditor(); + JCheckBox box = checkBoxIn(editor.getTableCellEditorComponent(table, false, false, 1, 0)); + + box.setSelected(true); + assertThat(editor.getCellEditorValue()).isEqualTo(true); + + box.setSelected(false); + assertThat(editor.getCellEditorValue()).isEqualTo(false); + } + + @Test + @DisplayName("opening a checkbox cell on a non-boolean value does not throw") + void nonBooleanValueDoesNotThrow() + { + // A checkbox column can be handed a null or a pasted string. Throwing here escapes onto + // the EDT when the user double-clicks the cell, leaving a cell that can never be edited. + CheckBoxEditor editor = new CheckBoxEditor(); + + java.util.List failures = CellsTestSupport.failuresOver( + java.util.Arrays.asList(null, "true", "", 0, 1, new Object()), + value -> editor.getTableCellEditorComponent(table, value, false, 1, 0)); + + assertThat(failures).as("values this checkbox editor cannot open on").isEmpty(); + } + + private static JCheckBox checkBoxIn(Component c) + { + if (c instanceof JCheckBox box) + return box; + if (c instanceof Container container) + { + for (Component child : container.getComponents()) + { + JCheckBox found = checkBoxIn(child); + if (found != null) + return found; + } + } + return null; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CheckBoxRendererTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CheckBoxRendererTest.java new file mode 100644 index 0000000..b6b1cb8 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/CheckBoxRendererTest.java @@ -0,0 +1,68 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.CheckBoxRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.*; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +class CheckBoxRendererTest +{ + private final JTable table = table(); + private final CheckBoxRenderer renderer = new CheckBoxRenderer(); + + @Test + @DisplayName("renders a component for every value a painting table could hand it, without throwing") + void totalOverEveryValue() + { + // Same paint()-time contract as every other renderer. A boolean column is not immune: + // a pasted cell, a freshly inserted row, or a format whose flag is absent all deliver + // something that is not a Boolean, and one of them stops the sheet painting for good. + List failures = failuresOver(hostileBooleanValues(), + value -> renderer.getTableCellRendererComponent(table, value, false, false, 1, 0)); + + assertThat(failures).as("values this checkbox renderer cannot render").isEmpty(); + } + + @Test + @DisplayName("shows a checkbox whose selection is the boolean it was given") + void selectionReflectsValue() + { + // The user reads the tick, not the model. If the tick does not track the value, every + // TM compatibility cell in the sheet lies about what is stored. + assertThat(checkBoxSelectedFor(Boolean.TRUE)).as("rendered state for true").isTrue(); + assertThat(checkBoxSelectedFor(Boolean.FALSE)).as("rendered state for false").isFalse(); + // and the component must not latch: back and forth, repeatedly + assertThat(checkBoxSelectedFor(Boolean.TRUE)).isTrue(); + assertThat(checkBoxSelectedFor(Boolean.FALSE)).isFalse(); + } + + private boolean checkBoxSelectedFor(Object value) + { + Component c = renderer.getTableCellRendererComponent(table, value, false, false, 1, 0); + JCheckBox box = findCheckBox(c); + assertThat(box).as("rendered component must contain a checkbox").isNotNull(); + return box.isSelected(); + } + + private static JCheckBox findCheckBox(Component c) + { + if (c instanceof JCheckBox box) + return box; + if (c instanceof Container container) + { + for (Component child : container.getComponents()) + { + JCheckBox found = findCheckBox(child); + if (found != null) + return found; + } + } + return null; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/ComboBoxCellEditorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/ComboBoxCellEditorTest.java new file mode 100644 index 0000000..bb4f138 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/ComboBoxCellEditorTest.java @@ -0,0 +1,194 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.editors.ComboBoxCellEditor; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.IndexedStringCellRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.util.Arrays; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class ComboBoxCellEditorTest +{ + private static final String[] ITEMS = names(21); + + private final JTable table = table(); + + private JComboBox comboOf(ComboBoxCellEditor editor, Object openOn) + { + return (JComboBox) editor.getTableCellEditorComponent(table, openOn, false, 1, 0); + } + + @Test + @DisplayName("offers exactly the options it was constructed with, in order") + void offersTheItemsItWasGiven() + { + // The dropdown is the user's entire view of the column's vocabulary. A missing or + // reordered option silently shifts every index the user picks. + ComboBoxCellEditor editor = new ComboBoxCellEditor(ITEMS); + JComboBox combo = comboOf(editor, 0); + + assertThat(combo.getItemCount()).isEqualTo(ITEMS.length); + for (int i = 0; i < ITEMS.length; i++) + assertThat(displayedTextAt(combo, i)).as("option %d", i).isEqualTo(ITEMS[i]); + } + + @Test + @DisplayName("picking the option shown for index i produces exactly i") + void pickingAnOptionProducesItsOwnIndex() + { + // Found by the text the user sees, not by the index the test already knows - that is the + // only way this catches a dropdown whose visible order and stored order have diverged. + ComboBoxCellEditor editor = new ComboBoxCellEditor(ITEMS); + JComboBox combo = comboOf(editor, 0); + + for (int i = 0; i < ITEMS.length; i++) + { + selectByDisplayedText(combo, ITEMS[i]); + assertThat(editor.getCellEditorValue()).as("value stored after picking '%s'", ITEMS[i]).isEqualTo(i); + } + } + + @Test + @DisplayName("what the renderer displays for a value is the option the editor stores that value for") + void rendererAndEditorAgreeOnEveryOption() + { + // The deep one. If the two disagree the user picks what they see and the sheet stores + // something else - the Evolutions method column writing "trade holding item" when the + // user picked a species name. + ComboBoxCellEditor editor = new ComboBoxCellEditor(ITEMS); + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + JComboBox combo = comboOf(editor, 0); + + for (int i = 0; i < combo.getItemCount(); i++) + { + String shownInDropdown = displayedTextAt(combo, i); + + // the user picks the row that says this, and the sheet stores... + selectByDisplayedText(combo, shownInDropdown); + Object stored = editor.getCellEditorValue(); + + // ...which the sheet then paints as... + JLabel painted = (JLabel) renderer.getTableCellRendererComponent(table, stored, false, false, 1, 0); + + assertThat(painted.getText()) + .as("cell text after picking option %d ('%s'), which stored %s", i, shownInDropdown, stored) + .isEqualTo(shownInDropdown); + } + } + + @Test + @DisplayName("opening a cell whose stored value is out of range does not throw") + void openingOnAnOutOfRangeValueDoesNotThrow() + { + // The editor half of the bug that made an out-of-range type unpaintable: the renderer now + // survives such a value, but the user still has to be able to open the cell to fix it. + // Throwing here escapes onto the EDT on a double click and the cell can never be repaired. + ComboBoxCellEditor editor = new ComboBoxCellEditor(ITEMS); + + List values = Arrays.asList(ITEMS.length, ITEMS.length + 5, 9999, -1, Integer.MAX_VALUE, null, "3", 3.5d); + List failures = failuresOver(values, + value -> editor.getTableCellEditorComponent(table, value, false, 1, 0)); + + assertThat(failures).as("stored values this editor cannot open a cell on").isEmpty(); + } + + @Test + @DisplayName("opening a cell preselects the option that value already means") + void openingPreselectsTheStoredValue() + { + // An editor that opens on the wrong row turns "double click, press escape" into a silent + // edit, because the sheet takes the editor's value when editing stops. + ComboBoxCellEditor editor = new ComboBoxCellEditor(ITEMS); + + for (int i = 0; i < ITEMS.length; i++) + { + JComboBox combo = comboOf(editor, i); + assertThat(displayedTextAt(combo, combo.getSelectedIndex())) + .as("option preselected for stored value %d", i).isEqualTo(ITEMS[i]); + assertThat(editor.getCellEditorValue()) + .as("value produced by opening on %d and touching nothing", i).isEqualTo(i); + } + } + + @Test + @DisplayName("after setItems the round trip holds against the new list") + void roundTripSurvivesSetItems() + { + // The sheet swaps text banks under a live table. If the editor and the renderer end up on + // different lists, every pick in that column stores the wrong index from then on. + ComboBoxCellEditor editor = new ComboBoxCellEditor(ITEMS); + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + + String[] replacement = {"alpha", "beta", "gamma", "delta"}; + editor.setItems(replacement); + renderer.setItems(replacement); + + JComboBox combo = comboOf(editor, 0); + assertThat(combo.getItemCount()).as("option count after setItems").isEqualTo(replacement.length); + + for (int i = 0; i < replacement.length; i++) + { + selectByDisplayedText(combo, replacement[i]); + Object stored = editor.getCellEditorValue(); + assertThat(stored).as("value stored after picking '%s'", replacement[i]).isEqualTo(i); + + JLabel painted = (JLabel) renderer.getTableCellRendererComponent(table, stored, false, false, 1, 0); + assertThat(painted.getText()).as("cell text for stored value %s", stored).isEqualTo(replacement[i]); + } + } + + @Test + @DisplayName("an edit that selects nothing leaves the cell as it was") + void noSelectionKeepsTheOriginalValue() + { + // A combo box reports -1 when nothing is selected, and that is what type-to-search + // leaves behind when the typed text matches no entry exactly. Reporting -1 as the new + // value made the sheet reject the edit outright, so typing a move name produced an error + // and the user had to find it in the list by hand. An edit that selected nothing should + // do nothing - which is how the numeric editor has always behaved. + ComboBoxCellEditor editor = new ComboBoxCellEditor(new String[] {"Tackle", "Growl", "Ember"}); + editor.getTableCellEditorComponent(new javax.swing.JTable(), 2, false, 0, 0); + + ((javax.swing.JComboBox) editor.getTableCellEditorComponent( + new javax.swing.JTable(), 2, false, 0, 0)).setSelectedIndex(-1); + + assertThat(editor.getCellEditorValue()) + .as("nothing selected means the cell keeps what it had") + .isEqualTo(2); + } + + @Test + @DisplayName("a real selection is reported as the new value") + void aSelectionIsReported() + { + // the other half: the no-op must not swallow a genuine edit + ComboBoxCellEditor editor = new ComboBoxCellEditor(new String[] {"Tackle", "Growl", "Ember"}); + javax.swing.JComboBox box = (javax.swing.JComboBox) editor.getTableCellEditorComponent( + new javax.swing.JTable(), 0, false, 0, 0); + box.setSelectedIndex(2); + + assertThat(editor.getCellEditorValue()).isEqualTo(2); + } + + @Test + @DisplayName("a value the column has no name for still opens, and closing changes nothing") + void outOfRangeValueOpensAndIsPreserved() + { + // a hacked ROM can hold an index past the name list. The renderer paints it harmlessly, + // so the editor must open too - and if the user closes without picking, the odd value + // must survive rather than being replaced by -1 or by 0. + ComboBoxCellEditor editor = new ComboBoxCellEditor(new String[] {"Tackle", "Growl"}); + + assertThatCode(() -> editor.getTableCellEditorComponent( + new javax.swing.JTable(), 99, false, 0, 0)).doesNotThrowAnyException(); + assertThat(editor.getCellEditorValue()) + .as("an unrecognised value is left alone, not overwritten") + .isEqualTo(99); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/DefaultSheetCellRendererTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/DefaultSheetCellRendererTest.java new file mode 100644 index 0000000..ca73f70 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/DefaultSheetCellRendererTest.java @@ -0,0 +1,99 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.DefaultSheetCellRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.Color; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultSheetCellRendererTest +{ + private final JTable table = table(); + private final DefaultSheetCellRenderer renderer = new DefaultSheetCellRenderer(); + + @Test + @DisplayName("renders a component for every value, in every selection state, without throwing") + void totalOverEveryValueAndState() + { + // This is the base class every other sheet renderer inherits from - if it can be made to + // throw, so can all of them, and the sheet stops painting. + List failures = failuresOver(hostileValues(), + value -> renderer.getTableCellRendererComponent(table, value, false, false, 1, 0)); + assertThat(failures).as("values unrenderable in the unselected state").isEmpty(); + + failures = failuresOver(hostileValues(), + value -> renderer.getTableCellRendererComponent(table, value, true, true, 0, 0)); + assertThat(failures).as("values unrenderable in the selected state").isEmpty(); + + table.setRowSelectionInterval(2, 2); + failures = failuresOver(hostileValues(), + value -> renderer.getTableCellRendererComponent(table, value, false, false, 2, 0)); + assertThat(failures).as("values unrenderable on the table's selected row").isEmpty(); + } + + @Test + @DisplayName("displays the value it was given, and blanks the cell for null") + void displaysTheValueItWasGiven() + { + // The striping/selection logic must not cost the cell its content: a renderer that paints + // the right colour and the wrong text is still a sheet that lies. + for (Object value : List.of(42, "text", 3.5d, Boolean.TRUE)) + { + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, value, false, false, 1, 0); + assertThat(label.getText()).as("text shown for %s", describe(value)).isEqualTo(String.valueOf(value)); + } + + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, null, false, false, 1, 0); + assertThat(label.getText()).as("text shown for null").isEmpty(); + } + + @Test + @DisplayName("gives adjacent unselected rows different backgrounds, so the striping is visible") + void alternatingRowsAreDistinguishable() + { + // The point of overriding the default renderer at all. Sentinel colours are installed for + // the theme keys the sheet stripes with, so the property under test is "adjacent rows are + // painted differently" rather than "the current look and feel happens to define them". + Color plain = new Color(1, 2, 3); + Color stripe = new Color(4, 5, 6); + table.setBackground(plain); + UIManager.put("TableHeader.pressedBackground", stripe); + try + { + table.clearSelection(); + Color even = renderer.getTableCellRendererComponent(table, "x", false, false, 0, 0).getBackground(); + Color odd = renderer.getTableCellRendererComponent(table, "x", false, false, 1, 0).getBackground(); + + assertThat(even).as("background of an even row").isNotNull(); + assertThat(odd).as("background of an odd row").isNotNull(); + assertThat(even).as("even/odd row backgrounds").isNotEqualTo(odd); + } + finally + { + UIManager.put("TableHeader.pressedBackground", null); + } + } + + @Test + @DisplayName("a cell rendered as unselected after a selected one does not keep the selected styling") + void stylingDoesNotLeakBetweenCells() + { + // One renderer instance paints every cell in the column in turn. State set for a selected + // cell that is not reset makes whole swathes of the sheet look selected. + table.clearSelection(); + JComponent first = (JComponent) renderer.getTableCellRendererComponent(table, "x", false, false, 1, 0); + Color backgroundFirst = first.getBackground(); + javax.swing.border.Border borderFirst = first.getBorder(); + + renderer.getTableCellRendererComponent(table, "x", true, true, 1, 0); + + JComponent again = (JComponent) renderer.getTableCellRendererComponent(table, "x", false, false, 1, 0); + assertThat(again.getBackground()).as("background of an unselected cell painted after a selected one").isEqualTo(backgroundFirst); + assertThat(again.getBorder()).as("border of an unselected cell painted after a selected one").isEqualTo(borderFirst); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/IndexedStringCellRendererTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/IndexedStringCellRendererTest.java new file mode 100644 index 0000000..3e1b438 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/IndexedStringCellRendererTest.java @@ -0,0 +1,134 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.IndexedStringCellRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.*; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +class IndexedStringCellRendererTest +{ + /** 21 names but only 18 colours - the exact shape that made a type value of 20 fatal. */ + private static final String[] ITEMS = names(21); + private static final Color[] COLORS = colors(18); + + private static Color[] colors(int count) + { + Color[] colors = new Color[count]; + for (int i = 0; i < count; i++) + colors[i] = new Color(i * 10, 0, 0); + return colors; + } + + private final JTable table = table(); + + @Test + @DisplayName("renders a component for every value a painting table could hand it, without throwing") + void totalOverEveryValue() + { + // A renderer runs inside paint(), once per visible cell. A value it cannot survive does + // not produce one bad cell - it makes the entire sheet permanently unpaintable, and the + // resulting exception storm never reaches the user. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + + List failures = failuresOver(hostileValues(), + value -> renderer.getTableCellRendererComponent(table, value, false, false, 1, 0)); + + assertThat(failures).as("values this renderer cannot render").isEmpty(); + } + + @Test + @DisplayName("coloured variant renders every value even when the colour list is shorter than the name list") + void coloredVariantTotalOverEveryValue() + { + // The regression this guards: the colour lookup indexed colors[] while the bounds check + // was against items[]. With 21 names and 18 colours, a type of 20 killed the sheet. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer.ColoredIndexedStringCellRenderer(ITEMS, COLORS); + + List failures = failuresOver(hostileValues(), + value -> renderer.getTableCellRendererComponent(table, value, false, false, 1, 0)); + + assertThat(failures).as("values the coloured renderer cannot render").isEmpty(); + } + + @Test + @DisplayName("an in-range index displays that index's name, not the raw number") + void inRangeIndexShowsItsName() + { + // The whole reason this renderer exists: the model stores an index, the user must see a name. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + + for (int i = 0; i < ITEMS.length; i++) + { + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, i, false, false, 1, 0); + assertThat(label.getText()).as("text shown for index %d", i).isEqualTo(ITEMS[i]); + } + } + + @Test + @DisplayName("an out-of-range index never displays some other entry's name") + void outOfRangeIndexShowsNoName() + { + // Silently showing the wrong name is worse than showing a number: the user believes the + // cell holds a value it does not hold, and edits around that belief. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + + for (Object value : List.of(ITEMS.length, ITEMS.length + 7, 9999, -1, Integer.MIN_VALUE)) + { + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, value, false, false, 1, 0); + assertThat(label.getText()).as("text shown for out-of-range value %s", value).isNotIn((Object[]) ITEMS); + } + } + + @Test + @DisplayName("the coloured variant paints an in-range value with that value's own colour") + void inRangeValueGetsItsOwnColor() + { + // Derived from the definition of the column: colour i belongs to index i. If the mapping + // slips, every type in the sheet is tinted as the wrong type. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer.ColoredIndexedStringCellRenderer(ITEMS, COLORS); + + for (int i = 0; i < COLORS.length; i++) + { + Component c = renderer.getTableCellRendererComponent(table, i, false, false, 1, 0); + assertThat(c.getBackground()).as("background for index %d", i).isEqualTo(COLORS[i]); + } + } + + @Test + @DisplayName("setItems re-points the renderer at the new names") + void setItemsChangesWhatIsDisplayed() + { + // The sheet swaps text banks under a live table (resetIndexedCellRendererText). A renderer + // that kept the old list would keep showing names from the previous ROM's text bank. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + String[] replacement = {"zero", "one", "two"}; + renderer.setItems(replacement); + + for (int i = 0; i < replacement.length; i++) + { + JLabel label = (JLabel) renderer.getTableCellRendererComponent(table, i, false, false, 1, 0); + assertThat(label.getText()).isEqualTo(replacement[i]); + } + } + + @Test + @DisplayName("still renders every value after the name list is swapped for a shorter one") + void totalAfterShrinkingItems() + { + // Indices legal against the old list are out of range against the new one; that transition + // is exactly when a missing bounds check bites. + IndexedStringCellRenderer renderer = new IndexedStringCellRenderer(ITEMS); + renderer.setItems(new String[] {"only"}); + + List failures = failuresOver(hostileValues(), + value -> renderer.getTableCellRendererComponent(table, value, false, false, 1, 0)); + + assertThat(failures).as("values this renderer cannot render after setItems").isEmpty(); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/MultiLineTableHeaderRendererTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/MultiLineTableHeaderRendererTest.java new file mode 100644 index 0000000..0dc4e82 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/cells/MultiLineTableHeaderRendererTest.java @@ -0,0 +1,74 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.cells; + +import io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.renderers.MultiLineTableHeaderRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.*; +import java.util.Arrays; +import java.util.List; + +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.failuresOver; +import static io.github.turtleisaac.pokeditor.gui.sheets.tables.cells.CellsTestSupport.table; +import static org.assertj.core.api.Assertions.assertThat; + +class MultiLineTableHeaderRendererTest +{ + private static final List HEADERS = Arrays.asList( + "Power", "", null, " ", + "Reflected By Magic Coat", + "a very long column heading that will certainly have to wrap more than once ".repeat(8), + "not markup", "& < > \" '", "line\nbreak", "\t", 42); + + private final JTable table = table(); + private final MultiLineTableHeaderRenderer renderer = new MultiLineTableHeaderRenderer(); + + @Test + @DisplayName("renders a component for any header text, including empty, null and markup-like strings") + void totalOverEveryHeaderText() + { + // The header is painted before any row is. A header string it cannot handle costs the + // user the entire sheet, not just the column label. + List failures = failuresOver(HEADERS, + value -> renderer.getTableCellRendererComponent(table, value, false, false, 0, 0)); + + assertThat(failures).as("header values this renderer cannot render").isEmpty(); + } + + @Test + @DisplayName("shows header text literally rather than interpreting it as markup") + void headerTextIsNotInterpretedAsMarkup() + { + // A JTextArea header is chosen precisely so a column name containing < or & shows as + // typed. Rendering it as HTML would silently swallow part of a column's name. + String raw = "not markup"; + JTextArea area = (JTextArea) renderer.getTableCellRendererComponent(table, raw, false, false, 0, 0); + + assertThat(area.getText()).isEqualTo(raw); + } + + @Test + @DisplayName("wraps long header text instead of rendering it as a single unreadable line") + void longHeaderTextWraps() + { + // The reason this renderer exists at all: sheet columns are narrow and their names are not. + JTextArea area = (JTextArea) renderer.getTableCellRendererComponent(table, "Reflected By Magic Coat", false, false, 0, 0); + + assertThat(area.getLineWrap()).as("line wrap").isTrue(); + assertThat(area.getWrapStyleWord()).as("word wrap").isTrue(); + assertThat(area.isEditable()).as("a header must not be editable").isFalse(); + } + + @Test + @DisplayName("sizes itself to the width of the column it is rendering") + void sizedToItsColumn() + { + // A header sized to some other column's width either clips its own name or overlaps its + // neighbour, which is how a column ends up looking like it has no name at all. + table.getColumnModel().getColumn(1).setWidth(37); + Component c = renderer.getTableCellRendererComponent(table, "Power", false, false, 0, 1); + + assertThat(c.getWidth()).isEqualTo(37); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/RealSheetModelContractTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/RealSheetModelContractTest.java new file mode 100644 index 0000000..cc98d7e --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/RealSheetModelContractTest.java @@ -0,0 +1,372 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.formats; + +import io.github.turtleisaac.pokeditor.formats.learnsets.LearnsetData; +import io.github.turtleisaac.pokeditor.gui.sheets.tables.FormatModelContract; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The same contract, pointed at the real sheet models. + *

+ * Each model is built directly rather than through its {@code DefaultTable}, because the table + * constructors need real move/type/item name banks pulled out of a ROM. The model is the part + * which owns the properties being asserted here, so that is the part which is constructed. + */ +class RealSheetModelContractTest +{ + private static final int ROWS = 4; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + // =============================================================== learnsets + + @Nested + @DisplayName("the level-up learnsets sheet") + class Learnsets + { + /** every row is an empty, correctly terminated learnset - the whole grid is tail */ + private LearnsetsTable.LearnsetsModel emptyModel() + { + return new LearnsetsTable.LearnsetsModel(SheetFixtures.emptyLearnsets(ROWS), SheetFixtures.textBanks(ROWS)); + } + + private LearnsetsTable.LearnsetsModel populatedModel() + { + return new LearnsetsTable.LearnsetsModel(SheetFixtures.learnsets(ROWS, 3), SheetFixtures.textBanks(ROWS)); + } + + @Test + @DisplayName("painting the sheet does not add moves to a Pokemon that has none") + void readingAnEmptyLearnsetDoesNotFillItIn() + { + // this is the learnsets bug: getValueFor used to pad the learnset out to whichever + // column was being painted, so scrolling injected move 0 at level 0 into every + // species, and those junk entries serialise ahead of the 0xFFFF terminator + FormatModelContract.assertReadsArePure(emptyModel()); + } + + @Test + @DisplayName("painting a sheet of populated learnsets does not change them either") + void readingAPopulatedLearnsetIsPure() + { + FormatModelContract.assertReadsArePure(populatedModel()); + } + + @Test + @DisplayName("the entry a learnset column refers to does not depend on which cells were painted first") + void readsAreIdempotent() + { + FormatModelContract.assertReadsAreIdempotent(populatedModel()); + } + + @Test + @DisplayName("every cell of the declared learnsets grid can be painted") + void everyCellIsReadable() + { + FormatModelContract.assertEveryCellIsReadable(emptyModel()); + FormatModelContract.assertEveryCellIsReadable(populatedModel()); + } + + @Test + @DisplayName("editing one move of one learnset leaves every other cell alone") + void writesAreLocal() + { + FormatModelContract.assertWritesAreLocal(populatedModel(), 1, 0, 25); + FormatModelContract.assertWritesAreLocal(populatedModel(), 2, 3, 40); + FormatModelContract.assertWritesAreLocal(populatedModel(), 0, 5, 100); + } + + @Test + @DisplayName("a move typed into a learnset is the move the sheet then shows") + void writesRoundTrip() + { + FormatModelContract.assertWriteRoundTrips(populatedModel(), 1, 0, 25); + FormatModelContract.assertWriteRoundTrips(populatedModel(), 1, 1, 33); + FormatModelContract.assertWriteRoundTrips(populatedModel(), 3, 5, 12); + } + + @Test + @DisplayName("the first unused slot of a learnset can be filled in") + void theFirstEmptySlotIsWritable() + { + // the boundary: the row's list has to grow by exactly one to reach the slot being + // written. A bound of '>' rather than '>=' stops growing one entry short and then + // indexes past the end, so the very first move anyone adds to a species throws. + FormatModelContract.assertWriteRoundTrips(emptyModel(), 0, 0, 20); + + // and the same boundary partway along a learnset which already has entries + LearnsetsTable.LearnsetsModel model = populatedModel(); + List data = model.getData(); + int firstFreeColumn = data.get(1).size() * 2; + FormatModelContract.assertWriteRoundTrips(model, 1, firstFreeColumn, 77); + } + + @Test + @DisplayName("filling in an unused learnset slot lengthens that Pokemon's learnset and nobody else's") + void growingOneRowDoesNotTouchTheOthers() + { + List data = SheetFixtures.learnsets(ROWS, 3); + LearnsetsTable.LearnsetsModel model = new LearnsetsTable.LearnsetsModel(data, SheetFixtures.textBanks(ROWS)); + + model.setValueAt(90, 1, 6); // the fourth move slot of row 1 + + assertThat(data.get(1)).as("the edited row gained exactly the slot that was written").hasSize(4); + for (int row = 0; row < ROWS; row++) + { + if (row == 1) + continue; + assertThat(data.get(row)) + .as("row %d was not edited and must not have grown", row) + .hasSize(3); + } + } + + @Test + @DisplayName("the learnsets sheet can store every move id and level it advertises, and refuses the rest") + void valueRangesAreHonest() + { + FormatModelContract.assertValueRangesAreHonest(emptyModel()); + } + } + + // =============================================================== personal + + @Nested + @DisplayName("the personal sheet") + class Personal + { + private PersonalTable.PersonalModel model() + { + return new PersonalTable.PersonalModel(SheetFixtures.personals(ROWS), SheetFixtures.textBanks(ROWS)); + } + + @Test + @DisplayName("painting the personal sheet does not change any Pokemon's stats") + void readsArePure() + { + FormatModelContract.assertReadsArePure(model()); + } + + @Test + @DisplayName("a personal cell reports the same value however much of the sheet is painted around it") + void readsAreIdempotent() + { + FormatModelContract.assertReadsAreIdempotent(model()); + } + + @Test + @DisplayName("every cell of the declared personal grid can be painted") + void everyCellIsReadable() + { + FormatModelContract.assertEveryCellIsReadable(model()); + } + + @Test + @DisplayName("editing one base stat leaves every other cell alone") + void writesAreLocal() + { + FormatModelContract.assertWritesAreLocal(model(), 1, 0, 120); // HP + FormatModelContract.assertWritesAreLocal(model(), 2, 10, 2); // HP EV yield + FormatModelContract.assertWritesAreLocal(model(), 0, 28, true); // flip + FormatModelContract.assertWritesAreLocal(model(), 3, -1, "Renamed"); + } + + @Test + @DisplayName("a base stat typed into the personal sheet is the value it then shows") + void writesRoundTrip() + { + FormatModelContract.assertWriteRoundTrips(model(), 1, 0, 120); + FormatModelContract.assertWriteRoundTrips(model(), 1, 5, "77"); + FormatModelContract.assertWriteRoundTrips(model(), 2, 28, true); + FormatModelContract.assertWriteRoundTrips(model(), 3, -1, "Renamed"); + } + + @Test + @DisplayName("the personal sheet can store every value it advertises, and refuses the rest") + void valueRangesAreHonest() + { + FormatModelContract.assertValueRangesAreHonest(model()); + } + } + + // =============================================================== moves + + @Nested + @DisplayName("the moves sheet") + class Moves + { + private MovesTable.MovesModel model() + { + return new MovesTable.MovesModel(SheetFixtures.moves(ROWS), SheetFixtures.textBanks(ROWS)); + } + + @Test + @DisplayName("painting the moves sheet does not change any move") + void readsArePure() + { + FormatModelContract.assertReadsArePure(model()); + } + + @Test + @DisplayName("a move cell reports the same value however much of the sheet is painted around it") + void readsAreIdempotent() + { + FormatModelContract.assertReadsAreIdempotent(model()); + } + + @Test + @DisplayName("every cell of the declared moves grid can be painted") + void everyCellIsReadable() + { + FormatModelContract.assertEveryCellIsReadable(model()); + } + + @Test + @DisplayName("editing one move's power leaves every other cell alone") + void writesAreLocal() + { + FormatModelContract.assertWritesAreLocal(model(), 1, 2, 90); // power + FormatModelContract.assertWritesAreLocal(model(), 2, 9, true); // makes contact + FormatModelContract.assertWritesAreLocal(model(), 0, -1, "Renamed"); + } + + @Test + @DisplayName("a value typed into the moves sheet is the value it then shows") + void writesRoundTrip() + { + FormatModelContract.assertWriteRoundTrips(model(), 1, 2, 90); + FormatModelContract.assertWriteRoundTrips(model(), 1, 8, -6); // priority, which is signed + FormatModelContract.assertWriteRoundTrips(model(), 3, 16, true); + } + + @Test + @DisplayName("the moves sheet can store every value it advertises, and refuses the rest") + void valueRangesAreHonest() + { + FormatModelContract.assertValueRangesAreHonest(model()); + } + } + + // =============================================================== evolutions + + @Nested + @DisplayName("the evolutions sheet") + class Evolutions + { + private EvolutionsTable.EvolutionsModel model() + { + return new EvolutionsTable.EvolutionsModel(SheetFixtures.evolutionRows(ROWS, 1), SheetFixtures.textBanks(ROWS)); + } + + @Test + @DisplayName("painting the evolutions sheet does not add evolutions to a Pokemon that has none") + void readsArePure() + { + FormatModelContract.assertReadsArePure(model()); + } + + @Test + @DisplayName("an evolution cell reports the same value however much of the sheet is painted around it") + void readsAreIdempotent() + { + FormatModelContract.assertReadsAreIdempotent(model()); + } + + @Test + @DisplayName("every cell of the declared evolutions grid can be painted") + void everyCellIsReadable() + { + FormatModelContract.assertEveryCellIsReadable(model()); + } + + @Test + @DisplayName("editing one evolution method leaves every other cell alone") + void writesAreLocal() + { + FormatModelContract.assertWritesAreLocal(model(), 1, 0, 4); + FormatModelContract.assertWritesAreLocal(model(), 2, 2, 25); + } + + @Test + @DisplayName("the first unused evolution slot can be filled in") + void writesRoundTrip() + { + FormatModelContract.assertWriteRoundTrips(model(), 1, 0, 4); + FormatModelContract.assertWriteRoundTrips(model(), 1, 3, 5); // the second evolution slot + } + + @Test + @DisplayName("the evolutions sheet can store every value it advertises, and refuses the rest") + void valueRangesAreHonest() + { + FormatModelContract.assertValueRangesAreHonest(model()); + } + } + + // =============================================================== tm compatibility + + @Nested + @DisplayName("the TM compatibility sheet") + class TmCompatibility + { + private TmCompatibilityTable.TmCompatibilityModel model() + { + return new TmCompatibilityTable.TmCompatibilityModel(SheetFixtures.personals(ROWS), SheetFixtures.textBanks(ROWS)); + } + + @Test + @DisplayName("painting the TM sheet does not change any Pokemon's TM flags") + void readsArePure() + { + FormatModelContract.assertReadsArePure(model()); + } + + @Test + @DisplayName("a TM checkbox reports the same value however much of the sheet is painted around it") + void readsAreIdempotent() + { + FormatModelContract.assertReadsAreIdempotent(model()); + } + + @Test + @DisplayName("every cell of the declared TM grid can be painted") + void everyCellIsReadable() + { + FormatModelContract.assertEveryCellIsReadable(model()); + } + + @Test + @DisplayName("ticking one TM leaves every other TM of every Pokemon alone") + void writesAreLocal() + { + FormatModelContract.assertWritesAreLocal(model(), 1, 0, true); + FormatModelContract.assertWritesAreLocal(model(), 2, 63, true); + FormatModelContract.assertWritesAreLocal(model(), 0, 99, true); + } + + @Test + @DisplayName("a ticked TM stays ticked") + void writesRoundTrip() + { + FormatModelContract.assertWriteRoundTrips(model(), 1, 0, true); + FormatModelContract.assertWriteRoundTrips(model(), 3, 99, "true"); + } + + @Test + @DisplayName("the TM sheet declares a well formed value range for every column") + void valueRangesAreHonest() + { + FormatModelContract.assertValueRangesAreHonest(model()); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/SheetFixtures.java b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/SheetFixtures.java new file mode 100644 index 0000000..9f316ee --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui/sheets/tables/formats/SheetFixtures.java @@ -0,0 +1,139 @@ +package io.github.turtleisaac.pokeditor.gui.sheets.tables.formats; + +import io.github.turtleisaac.pokeditor.formats.BytesDataContainer; +import io.github.turtleisaac.pokeditor.formats.evolutions.EvolutionData; +import io.github.turtleisaac.pokeditor.formats.learnsets.LearnsetData; +import io.github.turtleisaac.pokeditor.formats.moves.MoveData; +import io.github.turtleisaac.pokeditor.formats.personal.PersonalData; +import io.github.turtleisaac.pokeditor.formats.text.TextBankData; +import io.github.turtleisaac.pokeditor.gamedata.GameFiles; +import io.github.turtleisaac.pokeditor.gamedata.TextFiles; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +/** + * The smallest fixtures which will let a real sheet model be constructed at all. + *

+ * Everything here is deliberately trivial - an empty, correctly terminated learnset, a + * default-constructed personal entry, a text bank of placeholder strings. The suite is testing + * the table models, so a fixture which relied on PokEditor-Core parsing a real narc correctly + * would just move the thing under test somewhere the tests cannot see it. + */ +final class SheetFixtures +{ + private SheetFixtures() + { + } + + /** an empty learnset: just the 0xFFFF terminator, which is what an unused slot looks like */ + static LearnsetData emptyLearnset() + { + return learnset(); + } + + /** + * @param moveAndLevelPairs alternating move id and level + */ + static LearnsetData learnset(int... moveAndLevelPairs) + { + int entryCount = moveAndLevelPairs.length / 2; + ByteBuffer buf = ByteBuffer.allocate((entryCount + 1) * 2).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < entryCount; i++) + { + int move = moveAndLevelPairs[i * 2]; + int level = moveAndLevelPairs[i * 2 + 1]; + buf.putShort((short) (((level & 0x7F) << 9) | (move & 0x1FF))); + } + buf.putShort((short) 0xFFFF); + return new LearnsetData(new BytesDataContainer(GameFiles.LEVEL_UP_LEARNSETS, null, buf.array())); + } + + static List learnsets(int rows, int entriesPerRow) + { + List data = new ArrayList<>(); + for (int row = 0; row < rows; row++) + { + int[] pairs = new int[entriesPerRow * 2]; + for (int i = 0; i < entriesPerRow; i++) + { + pairs[i * 2] = i + 1; // move + pairs[i * 2 + 1] = i + 5; // level + } + data.add(learnset(pairs)); + } + return data; + } + + static List emptyLearnsets(int rows) + { + List data = new ArrayList<>(); + for (int row = 0; row < rows; row++) + data.add(emptyLearnset()); + return data; + } + + /** an evolution file holding {@code entriesPerRow} zeroed-out evolution records */ + static EvolutionData evolutions(int entryCount) + { + ByteBuffer buf = ByteBuffer.allocate(Math.max(1, entryCount) * 6).order(ByteOrder.LITTLE_ENDIAN); + return new EvolutionData(new BytesDataContainer(GameFiles.EVOLUTIONS, null, buf.array())); + } + + static List evolutionRows(int rows, int entriesPerRow) + { + List data = new ArrayList<>(); + for (int row = 0; row < rows; row++) + data.add(evolutions(entriesPerRow)); + return data; + } + + static List personals(int rows) + { + List data = new ArrayList<>(); + for (int row = 0; row < rows; row++) + data.add(new PersonalData()); + return data; + } + + static List moves(int rows) + { + List data = new ArrayList<>(); + for (int row = 0; row < rows; row++) + data.add(new MoveData()); + return data; + } + + /** + * A list of text banks long enough to be indexed by every {@link TextFiles} constant the + * sheets reach for, each holding {@code rows} placeholder strings. + */ + static List textBanks(int rows) + { + int highestBank = 0; + for (TextFiles file : TextFiles.values()) + { + try + { + highestBank = Math.max(highestBank, file.getValue()); + } + catch (IllegalStateException noRomLoaded) + { + // this bank's index is only known once a base ROM has been picked; the sheets + // under test do not reach for it, so a fixture does not have to cover it + } + } + + List banks = new ArrayList<>(); + for (int bank = 0; bank <= highestBank; bank++) + { + List messages = new ArrayList<>(); + for (int i = 0; i < rows; i++) + messages.add(new TextBankData.Message("Entry " + i)); + banks.add(new TextBankData(messages)); + } + return banks; + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui_old/CircleButtonTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui_old/CircleButtonTest.java new file mode 100644 index 0000000..6bd02eb --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui_old/CircleButtonTest.java @@ -0,0 +1,247 @@ +package io.github.turtleisaac.pokeditor.gui_old; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Property-based tests for {@link CircleButton}. + * + *

THEORY. A round button's hit region is the disc inscribed in its bounds: centre at the + * component's centre, radius half of the smaller side (the largest circle that fits). The + * geometric consequences are exact and independent of the implementation: + *

    + *
  • the centre is inside;
  • + *
  • a point at distance strictly less than the radius is inside, one at distance strictly + * greater is outside;
  • + *
  • the four corners of the bounding box are at distance sqrt((w/2)^2 + (h/2)^2) >= r, and are + * therefore outside. This is the discriminating case: the inherited rectangular + * {@code Component.contains} accepts every point of the bounds, corners included, so a + * button that never overrode it would look round but click square.
  • + *
+ * The radius is re-derived from the component's size at each size tested, so nothing here is + * hard-coded to one geometry. + */ +public class CircleButtonTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + private static final int[][] SIZES = {{40, 40}, {60, 40}, {40, 60}, {100, 100}, {24, 90}, {31, 31}, {17, 45}}; + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + private static CircleButton buttonOfSize(int width, int height) + { + CircleButton button = new CircleButton(); + button.setText("Go"); + button.setFont(new Font(Font.DIALOG, Font.PLAIN, 12)); + button.setSize(width, height); + return button; + } + + /** Radius of the inscribed circle, in pixels, derived from the component size alone. */ + private static double radiusOf(CircleButton button) + { + return Math.min(button.getWidth(), button.getHeight()) / 2.0; + } + + private static double distanceToCentre(CircleButton button, int x, int y) + { + return Point2D.distance(x, y, button.getWidth() / 2.0, button.getHeight() / 2.0); + } + + @Test + @DisplayName("the hit region is the inscribed disc, point by point over the whole bounds") + void hitRegionIsTheInscribedDisc() + { + // Even sizes only: the centre and the radius then land exactly on the pixel lattice, so + // the geometric prediction is unambiguous. Points within one pixel of the rim are skipped + // because whether the rim itself counts as inside is a convention, not a geometric fact. + for (int[] size : new int[][] {{40, 40}, {60, 40}, {40, 60}, {100, 100}}) + { + CircleButton button = buttonOfSize(size[0], size[1]); + double radius = radiusOf(button); + + for (int x = 0; x < button.getWidth(); x++) + { + for (int y = 0; y < button.getHeight(); y++) + { + double distance = distanceToCentre(button, x, y); + if (distance <= radius - 1.0) + assertThat(button.contains(x, y)) + .as("%dx%d: (%d,%d) is %.2f from the centre, inside radius %.1f", + size[0], size[1], x, y, distance, radius) + .isTrue(); + else if (distance >= radius + 1.0) + assertThat(button.contains(x, y)) + .as("%dx%d: (%d,%d) is %.2f from the centre, outside radius %.1f", + size[0], size[1], x, y, distance, radius) + .isFalse(); + } + } + } + } + + @Test + @DisplayName("the corners of the bounding box are outside the circle, at every size") + void cornersAreOutside() + { + for (int[] size : SIZES) + { + CircleButton button = buttonOfSize(size[0], size[1]); + int w = button.getWidth(); + int h = button.getHeight(); + int[][] corners = {{0, 0}, {w - 1, 0}, {0, h - 1}, {w - 1, h - 1}}; + + for (int[] corner : corners) + { + // A corner is at distance sqrt((w/2)^2+(h/2)^2) from the centre, which is at least + // the inscribed radius min(w,h)/2 and strictly greater whenever w,h > 0. Accepting + // it means the component is not round at all. + assertThat(button.contains(corner[0], corner[1])) + .as("%dx%d: corner (%d,%d) must not be inside the circle", w, h, corner[0], corner[1]) + .isFalse(); + } + } + } + + @Test + @DisplayName("the centre is inside and remains inside across resizes") + void centreIsAlwaysInside() + { + for (int[] size : SIZES) + { + CircleButton button = buttonOfSize(size[0], size[1]); + // Distance 0 < r for any circle with a positive radius. + assertThat(button.contains(button.getWidth() / 2, button.getHeight() / 2)) + .as("%dx%d: the centre must be inside", size[0], size[1]) + .isTrue(); + } + } + + @Test + @DisplayName("points just inside the rim hit and points just outside miss, at every size") + void rimBehaviourFollowsTheRadius() + { + for (int[] size : SIZES) + { + CircleButton button = buttonOfSize(size[0], size[1]); + int cx = button.getWidth() / 2; + int cy = button.getHeight() / 2; + int radius = (int) radiusOf(button); + + // Straight out along the four axes: one pixel short of the rim is inside the disc, + // one pixel past it is outside. Re-derived from the size, so this also fixes the + // resize-invariance of the hit test. + assertThat(button.contains(cx + radius - 1, cy)).as("%dx%d: inside right", size[0], size[1]).isTrue(); + assertThat(button.contains(cx - radius + 1, cy)).as("%dx%d: inside left", size[0], size[1]).isTrue(); + assertThat(button.contains(cx, cy + radius - 1)).as("%dx%d: inside below", size[0], size[1]).isTrue(); + assertThat(button.contains(cx, cy - radius + 1)).as("%dx%d: inside above", size[0], size[1]).isTrue(); + + assertThat(button.contains(cx + radius + 1, cy)).as("%dx%d: outside right", size[0], size[1]).isFalse(); + assertThat(button.contains(cx - radius - 1, cy)).as("%dx%d: outside left", size[0], size[1]).isFalse(); + assertThat(button.contains(cx, cy + radius + 1)).as("%dx%d: outside below", size[0], size[1]).isFalse(); + assertThat(button.contains(cx, cy - radius - 1)).as("%dx%d: outside above", size[0], size[1]).isFalse(); + } + } + + @Test + @DisplayName("points outside the bounds are outside the button") + void pointsBeyondTheBoundsAreOutside() + { + CircleButton button = buttonOfSize(50, 50); + // The disc is contained in the bounds, so anything outside the bounds is outside the disc. + assertThat(button.contains(-5, 25)).isFalse(); + assertThat(button.contains(25, -5)).isFalse(); + assertThat(button.contains(500, 25)).isFalse(); + assertThat(button.contains(25, 500)).isFalse(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("the preferred size is computable without a native peer") + void preferredSizeIsComputableBeforeDisplay() + { + CircleButton button = new CircleButton(); + button.setText("Go"); + button.setFont(new Font(Font.DIALOG, Font.PLAIN, 12)); + + // Every layout manager queries the preferred size while the component is still + // undisplayed, when getGraphics() is specified to return null. A preferred size that can + // only be computed once a peer exists cannot be laid out at all. + assertThatCode(button::getPreferredSize).doesNotThrowAnyException(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("an explicitly set preferred size wins, as JComponent specifies") + void explicitPreferredSizeIsHonoured() + { + CircleButton button = new CircleButton(); + button.setPreferredSize(new Dimension(64, 64)); + + // JComponent.getPreferredSize: if a preferred size has been set to a non-null value, it is + // returned. An override that ignores it takes away the caller's only way to size the + // button. + assertThat(button.getPreferredSize()).isEqualTo(new Dimension(64, 64)); + } + + @Test + @DisplayName("the computed preferred size is square and large enough for the label") + void computedPreferredSizeIsSquareAndFitsTheLabel() + { + F_MeasurableCircleButton button = new F_MeasurableCircleButton(); + button.setFont(new Font(Font.DIALOG, Font.PLAIN, 12)); + + for (String text : new String[] {"", "Go", "A much longer caption"}) + { + button.setText(text); + Dimension preferred = button.getPreferredSize(); + FontMetrics metrics = button.getGraphics().getFontMetrics(button.getFont()); + + // A circle's bounding box is square; anything else would leave the drawn circle + // smaller than the space reserved for it. + assertThat(preferred.width).as("square for text <%s>", text).isEqualTo(preferred.height); + // The label is drawn inside the circle, so the diameter must at least span the label's + // own width and height. + assertThat(preferred.width).as("fits the label width of <%s>", text).isGreaterThanOrEqualTo(metrics.stringWidth(text)); + assertThat(preferred.height).as("fits the label height of <%s>", text).isGreaterThanOrEqualTo(metrics.getHeight()); + } + } + + /** + * Test double: supplies the off-screen {@link Graphics} that an undisplayed component does not + * have, so the preferred-size computation itself can be exercised in a headless JVM. + */ + private static class F_MeasurableCircleButton extends CircleButton + { + private final BufferedImage image = new BufferedImage(200, 200, BufferedImage.TYPE_INT_ARGB); + + @Override + public Graphics getGraphics() + { + return image.getGraphics(); + } + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTreeTest.java b/src/test/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTreeTest.java new file mode 100644 index 0000000..7d8b6f3 --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/gui_old/JCheckboxTreeTest.java @@ -0,0 +1,394 @@ +package io.github.turtleisaac.pokeditor.gui_old; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Property-based tests for {@link JCheckboxTree}, a tri-state checkbox tree. + * + *

THEORY. The check state of a tree is a function from nodes to {UNCHECKED, PARTIAL, CHECKED} + * that is completely determined by which leaves are checked: + *

    + *
  • Downward propagation. Checking a node checks its entire subtree; unchecking it + * unchecks the entire subtree. The operation is a subtree-wide constant function.
  • + *
  • Upward consistency. A node is CHECKED iff all of its children are CHECKED, + * UNCHECKED iff none of its descendants are checked, and PARTIAL otherwise. In this + * implementation those two predicates are the fields {@code allChildrenSelected} (fully + * checked) and {@code isSelected} (at least one descendant checked).
  • + *
  • Leaves are two-state. A node with no children has no partial state to be in.
  • + *
  • Locality. An operation inside one subtree changes nothing in a sibling subtree.
  • + *
  • Idempotence. Repeating an operation changes nothing anywhere.
  • + *
+ * These are invariants, not endpoints: {@link #assertInvariant} re-derives them over the whole + * tree and is called after every mutation below. + */ +public class JCheckboxTreeTest +{ + /** + * This test asserts a property the code under it does not hold, and that code has no + * callers anywhere in src/main. It is kept as the specification for anyone who revives + * the class, and excluded from the build that has to stay green, so that a genuine + * regression elsewhere is still visible rather than lost among known failures. + */ + static final String DEAD_CODE = "dead-code"; + + private JCheckboxTree tree; + private final Map nodes = new LinkedHashMap<>(); + + @BeforeAll + static void headless() + { + System.setProperty("java.awt.headless", "true"); + } + + @BeforeEach + void buildTree() + { + nodes.clear(); + DefaultMutableTreeNode root = node("root"); + DefaultMutableTreeNode a = node("A"); + DefaultMutableTreeNode b = node("B"); + DefaultMutableTreeNode c = node("C"); // leaf child of root + DefaultMutableTreeNode a1 = node("A1"); + DefaultMutableTreeNode a2 = node("A2"); + DefaultMutableTreeNode b1 = node("B1"); + DefaultMutableTreeNode b2 = node("B2"); // leaf + DefaultMutableTreeNode b1a = node("B1a"); + DefaultMutableTreeNode b1b = node("B1b"); + + root.add(a); + root.add(b); + root.add(c); + a.add(a1); + a.add(a2); + b.add(b1); + b.add(b2); + b1.add(b1a); + b1.add(b1b); + + tree = new JCheckboxTree(root); + } + + private DefaultMutableTreeNode node(String name) + { + DefaultMutableTreeNode created = new DefaultMutableTreeNode(name); + nodes.put(name, created); + return created; + } + + private TreePath path(String name) + { + return new TreePath(nodes.get(name).getPath()); + } + + private JCheckboxTree.CheckedNode state(TreePath path) + { + return tree.nodesCheckingState.get(path); + } + + /** Exactly what the widget's mouse handler does for a click on the given node. */ + private void click(String name) + { + TreePath target = path(name); + boolean checkMode = !state(target).isSelected(); + tree.checkSubTree(target, checkMode); + tree.updatePredecessorsWithCheckMode(target, checkMode); + } + + private List allNodes() + { + List all = new ArrayList<>(); + collect((DefaultMutableTreeNode) tree.getModel().getRoot(), all); + return all; + } + + private void collect(DefaultMutableTreeNode node, List into) + { + into.add(node); + for (int i = 0; i < node.getChildCount(); i++) + collect((DefaultMutableTreeNode) node.getChildAt(i), into); + } + + /** Re-derives every tri-state invariant across the whole tree. */ + private void assertInvariant(String after) + { + for (DefaultMutableTreeNode node : allNodes()) + { + TreePath nodePath = new TreePath(node.getPath()); + JCheckboxTree.CheckedNode cn = state(nodePath); + + // Domain completeness: the check-state map is a total function on the tree's nodes. + assertThat(cn).as("%s: no check state tracked for %s", after, node).isNotNull(); + + // The cached structural flag must mirror the model it is derived from. + assertThat(cn.isHasChildren()) + .as("%s: hasChildren for %s", after, node) + .isEqualTo(node.getChildCount() > 0); + + if (node.getChildCount() > 0) + { + boolean anyChildSelected = false; + boolean allChildrenFull = true; + for (int i = 0; i < node.getChildCount(); i++) + { + JCheckboxTree.CheckedNode child = state(nodePath.pathByAddingChild(node.getChildAt(i))); + anyChildSelected |= child.isSelected(); + allChildrenFull &= child.isAllChildrenSelected(); + } + // Upward consistency, both halves. + assertThat(cn.isSelected()) + .as("%s: %s must be marked iff some child is marked", after, node) + .isEqualTo(anyChildSelected); + assertThat(cn.isAllChildrenSelected()) + .as("%s: %s must be fully checked iff every child is fully checked", after, node) + .isEqualTo(allChildrenFull); + // Fully checked implies checked: a parent cannot be "all children checked" while + // claiming nothing beneath it is checked. + if (cn.isAllChildrenSelected()) + assertThat(cn.isSelected()).as("%s: %s full but unmarked", after, node).isTrue(); + } + else + { + // A leaf has no children to disagree about, so it is never greyed. + assertThat(tree.isSelectedPartially(nodePath)) + .as("%s: leaf %s must never be partially checked", after, node) + .isFalse(); + } + + // The partial predicate is exactly "checked but not fully checked". + assertThat(tree.isSelectedPartially(nodePath)) + .as("%s: partial predicate for %s", after, node) + .isEqualTo(cn.isSelected() && cn.isHasChildren() && !cn.isAllChildrenSelected()); + + // The reported set of checked paths must agree with the per-node state; a path that + // renders as checked but is missing from getCheckedPaths() is silently dropped work. + assertThat(tree.checkedPaths.contains(nodePath)) + .as("%s: getCheckedPaths() membership for %s", after, node) + .isEqualTo(cn.isSelected()); + } + } + + private Map snapshot() + { + Map snapshot = new LinkedHashMap<>(); + for (DefaultMutableTreeNode node : allNodes()) + { + JCheckboxTree.CheckedNode cn = state(new TreePath(node.getPath())); + snapshot.put(node.getUserObject().toString(), cn.isSelected() + "/" + cn.isAllChildrenSelected()); + } + return snapshot; + } + + @Test + @DisplayName("a freshly built tree has nothing checked and satisfies the invariant") + void freshTreeIsConsistent() + { + // The initial state is the everywhere-unchecked function, which trivially satisfies both + // halves of upward consistency. + assertInvariant("construction"); + assertThat(tree.getCheckedPaths()).isEmpty(); + for (DefaultMutableTreeNode node : allNodes()) + assertThat(state(new TreePath(node.getPath())).isSelected()).as("%s", node).isFalse(); + } + + @Test + @DisplayName("checking a node checks its whole subtree and nothing outside it") + void checkingPropagatesDownAndStaysLocal() + { + Map before = snapshot(); + click("B"); + assertInvariant("click B"); + + // Downward propagation: the subtree rooted at B is now a constant CHECKED. + for (String name : new String[] {"B", "B1", "B2", "B1a", "B1b"}) + { + assertThat(state(path(name)).isSelected()).as("%s selected", name).isTrue(); + assertThat(state(path(name)).isAllChildrenSelected()).as("%s fully checked", name).isTrue(); + } + + // Locality: the sibling subtree A and the leaf C are untouched by an operation on B. + for (String name : new String[] {"A", "A1", "A2", "C"}) + assertThat(snapshot().get(name)).as("%s must be unchanged", name).isEqualTo(before.get(name)); + } + + @Test + @DisplayName("checking one deep leaf makes exactly its ancestors partial") + void checkingALeafMakesAncestorsPartial() + { + click("B1a"); + assertInvariant("click B1a"); + + // Upward consistency: one checked leaf marks each ancestor as checked-but-not-full. + for (String name : new String[] {"B1", "B", "root"}) + { + assertThat(tree.isSelectedPartially(path(name))).as("%s partial", name).isTrue(); + assertThat(state(path(name)).isAllChildrenSelected()).as("%s not full", name).isFalse(); + } + // ...and leaves nothing else marked. + for (String name : new String[] {"A", "A1", "A2", "B2", "B1b", "C"}) + assertThat(state(path(name)).isSelected()).as("%s untouched", name).isFalse(); + } + + @Test + @DisplayName("checking every child promotes the parent from partial to fully checked") + void allChildrenCheckedPromotesTheParent() + { + click("B1a"); + assertInvariant("click B1a"); + assertThat(tree.isSelectedPartially(path("B1"))).isTrue(); + + click("B1b"); + assertInvariant("click B1b"); + // "Checked iff all children checked" is now satisfied for B1, so it must stop being grey. + assertThat(state(path("B1")).isAllChildrenSelected()).isTrue(); + assertThat(tree.isSelectedPartially(path("B1"))).isFalse(); + // B still has an unchecked child (B2), so B stays partial: the rule is per-node. + assertThat(tree.isSelectedPartially(path("B"))).isTrue(); + } + + @Test + @DisplayName("checking an already-checked node changes nothing anywhere") + void checkingIsIdempotent() + { + click("B1a"); + assertInvariant("click B1a"); + Map after = snapshot(); + + TreePath target = path("B1a"); + tree.checkSubTree(target, true); + tree.updatePredecessorsWithCheckMode(target, true); + assertInvariant("re-check B1a"); + + // f(f(x)) == f(x): a second identical operation is the identity on the whole tree. + assertThat(snapshot()).isEqualTo(after); + } + + @Test + @DisplayName("unchecking undoes checking exactly (involution on the whole tree)") + void checkThenUncheckRestoresTheInitialState() + { + Map initial = snapshot(); + click("B"); + assertInvariant("click B"); + click("B"); + assertInvariant("click B again"); + // Toggling a node twice is the identity, including for every ancestor it dragged along. + assertThat(snapshot()).isEqualTo(initial); + assertThat(tree.getCheckedPaths()).isEmpty(); + } + + @Test + @DisplayName("checkRoot() checks every node in the tree") + void checkRootChecksEverything() + { + tree.checkRoot(); + assertInvariant("checkRoot"); + + // The root's subtree is the whole tree, so downward propagation from it is total. + List all = allNodes(); + assertThat(tree.getCheckedPaths()).hasSize(all.size()); + for (DefaultMutableTreeNode node : all) + { + assertThat(state(new TreePath(node.getPath())).isSelected()).as("%s", node).isTrue(); + assertThat(state(new TreePath(node.getPath())).isAllChildrenSelected()).as("%s", node).isTrue(); + assertThat(tree.isSelectedPartially(new TreePath(node.getPath()))).as("%s", node).isFalse(); + } + } + + @Test + @DisplayName("the invariant survives a long random sequence of user gestures") + void invariantHoldsAfterEveryGestureInARandomSequence() + { + List names = new ArrayList<>(nodes.keySet()); + Random random = new Random(20260823L); + StringBuilder history = new StringBuilder(); + + for (int step = 0; step < 120; step++) + { + String target = names.get(random.nextInt(names.size())); + history.append(target).append(' '); + click(target); + // The invariant is a state predicate, so it must hold after every single operation, + // not merely once the sequence has finished. + assertInvariant("gesture sequence [" + history + "]"); + } + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("the public checkSubTree leaves the tree in a consistent state") + void publicCheckSubTreeMaintainsTheInvariant() + { + // checkSubTree is public API. Any public mutator must leave its object satisfying the + // object's own invariant - a caller that is not also told to run the private upward pass + // otherwise ends up with a parent that renders as unchecked while its children are checked, + // and getCheckedPaths() that omits them. + tree.checkSubTree(path("B1a"), true); + assertInvariant("public checkSubTree(B1a, true)"); + } + + @Test + @DisplayName("swapping the model resets the check state and keeps the invariant") + void modelSwapResetsCheckingState() + { + click("B"); + assertInvariant("click B"); + + DefaultMutableTreeNode newRoot = new DefaultMutableTreeNode("newRoot"); + DefaultMutableTreeNode child = new DefaultMutableTreeNode("newChild"); + newRoot.add(child); + tree.setModel(new DefaultTreeModel(newRoot)); + + // The check state is a function on the CURRENT model's nodes; after a swap its domain is + // the new tree and nothing in it can be checked, since the user has checked nothing there. + assertInvariant("model swap"); + assertThat(tree.getCheckedPaths()).isEmpty(); + assertThat(state(new TreePath(newRoot.getPath()))).isNotNull(); + assertThat(state(new TreePath(child.getPath()))).isNotNull(); + + assertThatCode(tree::checkRoot).doesNotThrowAnyException(); + assertInvariant("checkRoot after model swap"); + assertThat(tree.getCheckedPaths()).hasSize(2); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("a null model is accepted, as JTree specifies") + void nullModelIsAccepted() + { + // JTree.setModel accepts null (getModel() may return null and the UI copes), so an + // override that dies on it narrows the contract of the class it extends. + assertThatCode(() -> tree.setModel(null)).doesNotThrowAnyException(); + } + + @Tag(DEAD_CODE) + @Test + @DisplayName("a node added to the model is tracked by the check state") + void nodesAddedToTheModelAreTracked() + { + DefaultTreeModel model = (DefaultTreeModel) tree.getModel(); + DefaultMutableTreeNode added = new DefaultMutableTreeNode("A3"); + model.insertNodeInto(added, nodes.get("A"), nodes.get("A").getChildCount()); + + // The check-state map must remain a total function on the model's nodes: the model fires + // a structural change, so a widget deriving state from it has to follow. An untracked node + // makes every later operation on its ancestors dereference a missing entry. + assertThat(state(new TreePath(added.getPath()))).as("check state for a newly inserted node").isNotNull(); + assertThatCode(tree::checkRoot).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculatorTest.java b/src/test/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculatorTest.java new file mode 100644 index 0000000..b6575de --- /dev/null +++ b/src/test/java/io/github/turtleisaac/pokeditor/utilities/TrainerPersonalityCalculatorTest.java @@ -0,0 +1,254 @@ +package io.github.turtleisaac.pokeditor.utilities; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This class reimplements the game's own generator, so its properties come from the algorithm's + * definition rather than from what the Java happens to produce today. + *

+ * The generator is the 32-bit LCG {@code s' = (0x41C64E6D * s + 0x6073) mod 2^32}. Every + * expectation below is computed from that definition with exact arithmetic, deliberately not by + * repeating the production expression, so a sign-extension or overflow mistake in the shipped + * masking cannot agree with the oracle. + */ +class TrainerPersonalityCalculatorTest +{ + private static final BigInteger MULTIPLIER = BigInteger.valueOf(0x41C64E6DL); + private static final BigInteger INCREMENT = BigInteger.valueOf(0x6073L); + private static final BigInteger MODULUS = BigInteger.ONE.shiftLeft(32); + + /** The next state of the LCG, computed exactly, independently of how the code does it. */ + private static long nextState(long seed) + { + return MULTIPLIER.multiply(BigInteger.valueOf(seed)).add(INCREMENT).mod(MODULUS).longValueExact(); + } + + @ParameterizedTest(name = "seed {0}") + @ValueSource(longs = {0L, 1L, 12345L, 0x12345678L, 0x7FFFFFFFL, 0x80000000L, 0xFFFFFFFFL}) + @DisplayName("each draw is the LCG's next state, computed in exact arithmetic") + void drawsFollowTheLcgDefinition(long seed) + { + TrainerPersonalityCalculator.setRandom(seed); + + long state = seed; + for (int step = 0; step < 25; step++) + { + state = nextState(state); + assertThat(TrainerPersonalityCalculator.random()) + .as("draw %d from seed %d", step, seed) + .isEqualTo(state); + } + } + + /** + * The generator is 32 bits wide. Anything outside that range means a shift or a mask has + * escaped, and every downstream consumer here slices 16-bit halves out of the result. + */ + @Test + @DisplayName("every draw stays inside 32 unsigned bits") + void drawsStayWithinThirtyTwoBits() + { + TrainerPersonalityCalculator.setRandom(0x9E3779B9L); + + for (int i = 0; i < 5_000; i++) + { + long draw = TrainerPersonalityCalculator.random(); + assertThat(draw).as("draw %d", i).isBetween(0L, 0xFFFFFFFFL); + } + } + + /** + * Determinism is the whole point of reproducing the game's generator: a given seed has to + * replay the same sequence, otherwise a PID computed here cannot be reproduced in-game. + */ + @Test + @DisplayName("the same seed replays the same sequence") + void sameSeedProducesSameSequence() + { + long[] first = drawMany(0xDEADBEEFL, 100); + long[] second = drawMany(0xDEADBEEFL, 100); + + assertThat(second).isEqualTo(first); + } + + /** + * ...and a different seed must not. Without this, a generator stuck on a constant would + * satisfy the determinism property above. + */ + @Test + @DisplayName("different seeds produce different sequences") + void differentSeedsDiverge() + { + assertThat(drawMany(1L, 20)).isNotEqualTo(drawMany(2L, 20)); + } + + private static long[] drawMany(long seed, int count) + { + TrainerPersonalityCalculator.setRandom(seed); + long[] draws = new long[count]; + for (int i = 0; i < count; i++) + draws[i] = TrainerPersonalityCalculator.random(); + return draws; + } + + /** + * The trainer ID pair is a packed 32-bit value: TID in bits 16-31 and SID in bits 0-15, and + * the callers unpack it with exactly those masks. Splicing a 32-bit draw into the high half + * rather than that draw's own 16-bit slice pushes the result up to 48 bits, which then makes + * {@code (id >> 16) & 0xffff} read a mixture of two draws instead of a TID. + */ + @Test + @DisplayName("a generated ID pair fits in 32 bits, so TID and SID are each 16 bits") + void idPairFitsInThirtyTwoBits() + { + TrainerPersonalityCalculator.setRandom(0x1234L); + + for (int i = 0; i < 2_000; i++) + { + long id = TrainerPersonalityCalculator.rndFlagCall(); + + assertThat(id).as("packed id %d", i).isBetween(0L, 0xFFFFFFFFL); + assertThat((id >> 16) & 0xffff).as("TID of id %d", i).isBetween(0L, 65535L); + assertThat(id & 0xffff).as("SID of id %d", i).isBetween(0L, 65535L); + } + } + + /** + * Each half of the ID pair is its own draw's high 16 bits, in order: TID from the first draw, + * SID from the second. Stated against draws taken independently from the same seed, so it + * pins which bits of which draw land where. + */ + @Test + @DisplayName("the two halves of an ID pair are the high 16 bits of two consecutive draws") + void idPairHalvesComeFromConsecutiveDraws() + { + long seed = 0xABCDEF01L; + + TrainerPersonalityCalculator.setRandom(seed); + long firstDraw = TrainerPersonalityCalculator.random(); + long secondDraw = TrainerPersonalityCalculator.random(); + + TrainerPersonalityCalculator.setRandom(seed); + long id = TrainerPersonalityCalculator.rndFlagCall(); + + assertThat((id >> 16) & 0xffff).as("TID").isEqualTo((firstDraw >> 16) & 0xffff); + assertThat(id & 0xffff).as("SID").isEqualTo((secondDraw >> 16) & 0xffff); + } + + /** + * An ID pair costs exactly two draws. The generator state is shared and the game advances it + * in lockstep, so consuming a different number of draws desynchronises everything after it. + */ + @Test + @DisplayName("generating an ID pair advances the generator by exactly two draws") + void idPairConsumesTwoDraws() + { + TrainerPersonalityCalculator.setRandom(777L); + TrainerPersonalityCalculator.rndFlagCall(); + long afterIdPair = TrainerPersonalityCalculator.random(); + + TrainerPersonalityCalculator.setRandom(777L); + TrainerPersonalityCalculator.random(); + TrainerPersonalityCalculator.random(); + long afterTwoDraws = TrainerPersonalityCalculator.random(); + + assertThat(afterIdPair).isEqualTo(afterTwoDraws); + } + + /** + * A PID here is built as a 16-bit slice scaled by 256 plus a gender constant, so it occupies + * at most 24 bits and is never negative. A negative or wider value means the arithmetic + * overflowed the int it is returned in, and the value would be written back out truncated. + */ + @Test + @DisplayName("a generated PID is non-negative and fits in 24 bits") + void generatedPidFitsInTwentyFourBits() + { + for (int difficulty = 0; difficulty <= 65535; difficulty += 97) + { + int pid = TrainerPersonalityCalculator.generatePid(320, 3, true, 466, 50, difficulty, 0, false); + assertThat(pid).as("PID for difficulty %d", difficulty).isBetween(0, 0xFFFFFF); + } + } + + /** + * generatePid seeds the generator itself, so it is a pure function of its arguments. If it + * ever leaked the ambient generator state the same trainer would get a different PID + * depending on what the tool happened to do beforehand. + */ + @Test + @DisplayName("a PID depends only on its arguments, not on the generator state it inherits") + void generatedPidIgnoresAmbientGeneratorState() + { + TrainerPersonalityCalculator.setRandom(0L); + int fromZero = TrainerPersonalityCalculator.generatePid(320, 4, true, 466, 50, 2500, 0, false); + + TrainerPersonalityCalculator.setRandom(0xFFFFFFFFL); + for (int i = 0; i < 13; i++) + TrainerPersonalityCalculator.random(); + int fromElsewhere = TrainerPersonalityCalculator.generatePid(320, 4, true, 466, 50, 2500, 0, false); + + assertThat(fromElsewhere).isEqualTo(fromZero); + } + + /** + * The search domain is the whole 16-bit difficulty range, 0 to 65535 inclusive. These + * arguments were chosen because 65535 is the only value in that domain which produces this + * PID, so a search bounded by {@code i < 65535} has nowhere else to land and returns -1. + * The target is derived from 65535 rather than hard-coded, so the witness cannot rot into a + * recording of a number. + */ + @Test + @DisplayName("the brute force search reaches the last value of its domain, 65535") + void bruteForceCoversTheTopOfItsDomain() + { + int trainerIdx = 320, trainerClassIdx = 1, speciesIdx = 466, level = 50; + + int target = TrainerPersonalityCalculator.generatePid(trainerIdx, trainerClassIdx, true, speciesIdx, level, 65535, 0, false); + + assertThat(TrainerPersonalityCalculator.bruteForcePid(target, trainerIdx, trainerClassIdx, true, speciesIdx, level)) + .as("the difficulty value which produces PID %d", target) + .isEqualTo(65535); + } + + /** + * Whatever index comes back must actually reproduce the PID that was searched for - the + * search is only useful if its answer round trips. + */ + @Test + @DisplayName("a found difficulty value reproduces the PID that was searched for") + void bruteForceResultReproducesTheTarget() + { + int trainerIdx = 100, trainerClassIdx = 2, speciesIdx = 25, level = 30; + + for (int difficulty : new int[] {0, 1, 255, 4096, 32768, 65534, 65535}) + { + int target = TrainerPersonalityCalculator.generatePid(trainerIdx, trainerClassIdx, true, speciesIdx, level, difficulty, 0, false); + int found = TrainerPersonalityCalculator.bruteForcePid(target, trainerIdx, trainerClassIdx, true, speciesIdx, level); + + assertThat(found).as("search for the PID of difficulty %d", difficulty).isNotEqualTo(-1); + assertThat(TrainerPersonalityCalculator.generatePid(trainerIdx, trainerClassIdx, true, speciesIdx, level, found, 0, false)) + .as("PID regenerated from the difficulty value the search returned for %d", difficulty) + .isEqualTo(target); + } + } + + /** + * A PID no difficulty value can produce has to be reported as not found, rather than the + * search returning whatever index it stopped on. Every PID here is at least 120, so 0 is + * unreachable by construction. + */ + @Test + @DisplayName("an unreachable PID is reported as not found") + void bruteForceReportsAnUnreachableTarget() + { + assertThat(TrainerPersonalityCalculator.bruteForcePid(0, 320, 1, true, 466, 50)).isEqualTo(-1); + } +}