Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consistency checks for dynamic strings, before adding "Frère Jacques" song. #88

Merged
merged 7 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/com/nicobrailo/pianoli/Theme.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import androidx.core.graphics.ColorUtils;

public class Theme {
public static final String PREFIX = "theme_";

private final KeyColor[] colors;

Expand Down
19 changes: 14 additions & 5 deletions app/src/main/java/com/nicobrailo/pianoli/melodies/Melody.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.nicobrailo.pianoli.melodies;

import android.util.Log;
import com.nicobrailo.pianoli.song.ImALittleTeapot;
import com.nicobrailo.pianoli.song.InsyWinsySpider;
import com.nicobrailo.pianoli.song.TwinkleTwinkleLittleStar;
import com.nicobrailo.pianoli.song.WaltzingMatilda;
import com.nicobrailo.pianoli.song.*;
import org.jetbrains.annotations.NotNull;

/**
* Parsed representation of a children's song.
Expand All @@ -15,6 +13,8 @@ public class Melody {
/** Log tag */
public static final String TAG = "MELODY";

public static final String PREFIX = "melody_";

/**
* All songs known to PianOli.
*
Expand All @@ -28,7 +28,8 @@ public class Melody {
TwinkleTwinkleLittleStar.melody,
InsyWinsySpider.melody,
ImALittleTeapot.melody,
WaltzingMatilda.melody
WaltzingMatilda.melody,
BrotherJohn.melody,
};

/**
Expand Down Expand Up @@ -76,4 +77,12 @@ public String getId() {
public int[] getNotes() {
return notes;
}

@NotNull
@Override
public String toString() {
return "Melody{" +
'\'' + id + '\'' +
", " + notes.length + " notes}";
}
}
44 changes: 44 additions & 0 deletions app/src/main/java/com/nicobrailo/pianoli/song/BrotherJohn.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.nicobrailo.pianoli.song;

import com.nicobrailo.pianoli.melodies.Melody;

/**
* The massively translated Frère Jacques ("Are you sleeping, Brother John?")
*
* <p>
* This nursery rhyme has been translated to dozens of languages, so it should reach a very wide audience.
* </p>
*
* <p>
* Compared to the classic C-D-E-C arrangement, this version is up-shifted by three full notes,
* to account for the bass-note the final chord. Without this shift, it would fall below where we have sound samples.
* </p>
*
*
* <p>
* Further reading:<ul>
* <li><a href="https://en.wikipedia.org/wiki/Fr%C3%A8re_Jacques">English Wikipedia: Frère Jacques</a></li>
* <li><a href="https://de.wikipedia.org/wiki/Fr%C3%A8re_Jacques">German Wikipedia: Frère Jacques</a> (listing some 50 translations)</li>
* </ul></p>
*/
public class BrotherJohn {
public static final Melody melody = Melody.fromString(
"brother_john",
" " + // 'useless' string so that code formatting indentation nicely lines up
// Are you sleeping, 2x
"F G A F " +
"F G A F " +

// Brother John? 2x
"A A# C2 " +
"A A# C2 " +

// Morning bells are ringing! 2x
"C2 D2 C2 A# A F " +
"C2 D2 C2 A# A F " +

// Please come on! 2x
"F C F " +
"F C F "
);
}
4 changes: 3 additions & 1 deletion app/src/main/res/values-de-rDE/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@
<string name="theme_black_and_white">schwarz-weiß</string>
<string name="melody_twinkle_twinkle_little_star">Morgen kommt der Weihnachtsmann</string>
<string name="melody_waltzing_matilda">Waltzing Matilda</string>
</resources>
<string name="soundset_piano2">Piano 2</string>
<string name="melody_brother_john">Bruder Jakob</string>
</resources>
4 changes: 3 additions & 1 deletion app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@
<string name="melody_im_a_little_teapot">Ik ben een Kleine Theepot</string>
<string name="preferences">Voorkeuren</string>
<string name="melody_waltzing_matilda">Waltzing Matilda</string>
</resources>
<string name="soundset_piano2">Piano 2</string>
<string name="melody_brother_john">Vader Jacob</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
<string name="soundset_guitar">Guitar</string>
<string name="soundset_musicbox">Music Box</string>
<string name="soundset_piano">Piano</string>
<string name="soundset_piano2">Piano 2</string>
<string name="soundset_sine">Sine Wave</string>
<string name="soundset_vibraphone">Vibraphone</string>


<!-- Song player settings -->
<string name="pref_enable_melodies">Auto play melodies</string>
<string name="pref_enable_melodies_summary">Touching a key plays the next note in a melody, rather than the key\'s
Expand All @@ -34,6 +36,7 @@
<string name="melody_im_a_little_teapot">I\'m a Little Teapot</string>
<string name="melody_insy_winsy_spider">Incy Winsy Spider</string>
<string name="melody_waltzing_matilda">Waltzing Matilda</string>
<string name="melody_brother_john">Brother John</string>

<!-- keyboard colour themes -->
<string name="theme">Key Colours</string>
Expand Down
16 changes: 16 additions & 0 deletions app/src/test/java/com/nicobrailo/pianoli/AssertionsExt.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.nicobrailo.pianoli;

import org.junit.jupiter.api.Assertions;

import java.util.Collection;

public class AssertionsExt {
/**
* Fails (with <code>message</code>) if collection <code>haystack</code> does not contain <code>needle</code>.
*/
public static <T> void assertContains(Collection<T> haystack, T needle, String message) {
if (!haystack.contains(needle)) {
Assertions.fail(message);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package com.nicobrailo.pianoli;

import android.content.Context;
import android.content.res.AssetManager;
import com.nicobrailo.pianoli.melodies.Melody;
import com.nicobrailo.pianoli.sound.SoundSet;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.nicobrailo.pianoli.AssertionsExt.*;

/**
* Consistency checks for dynamically-accessed entities: do they have translation-strings?
*
* <p>There are a few entities in PianOli that are derived dynamically at runtime.
* The IDE/Compiler cannot us in ensuring these have internationalisation strings, or that a particular string
* is never used.
* These unit tests cover those holes for the following entities:
* <ul>
* <li>SoundSets</li>
* <li>Themes</li>
* <li>Melodies</li>
* </ul>
* </p>
*/
public class DynamicTranslationIdentifierTest {
/**
* Ensures that all available {@link com.nicobrailo.pianoli.sound.SampledSoundSet}s are translatable.
*
* <p>
* It does so by checking if each "soundset_FOO" folder under <code>src/main/assets/</code> has
* a matching entry in <code>src/main/res/values/strings.xml</code>.
* </p>
* <p>
* We do <em>not</em> test if the translation exists in all languages; test-failures due to (lack of) translation
* progress is a distraction, and not something the developer can control.
* Instead, we ensure that
* </p>
*
* @see com.nicobrailo.pianoli.sound.SampledSoundSet
* @see SoundSet#PREFIX
* @see SoundSet#getAvailableSoundsets(AssetManager)
* @see SettingsFragment#loadSounds()
*/
@ParameterizedTest
@MethodSource("getSoundSets")
public void testSoundsetsHaveTranslationEntities(Path soundsetAssetFolder) {
List<String> translatables = getSoundSetTranslations();

String folderName = soundsetAssetFolder.getFileName().toString();

assertContains(translatables, folderName,
"Asset folder '" + soundsetAssetFolder + "' has no translation string in app/src/main/res/values/strings.xml");
}

/**
* Ensures our translations actually have a soundset backing them.
*
* <p>
* Inverse of {@link #testSoundsetsHaveTranslationEntities(Path)}, useful if we rename or delete asset folders.
* </p>
*/
@ParameterizedTest
@MethodSource("getSoundSetTranslations")
public void testNoLeftoverSoundSetTranslations(String translationIdentifier) throws IOException {
List<String> soundSetAssets = getSoundSets().stream()
.map(Path::getFileName)
.map(Path::toString)
.collect(Collectors.toList());

assertContains(soundSetAssets, translationIdentifier,
"Translation id '" + translationIdentifier + "' translates a soundset that doesn't exist in src/main/assets/");
}


/**
* Ensures that all specific {@link Theme}s are translatable.
*
* @see Theme
* @see Theme#PREFIX
* @see Theme#fromPreferences(Context)
* @see Preferences#selectedTheme(Context)
* @see R.xml#root_preferences
*/
@ParameterizedTest
@MethodSource("getThemes")
public void testThemesHaveTranslationEntities(String themeName) {
List<String> translatables = getThemeTranslations();

String matchingForm = Theme.PREFIX + themeName.toLowerCase(Locale.ROOT);
assertContains(translatables, matchingForm,
"Theme '" + themeName + "' has no translation string ('" + matchingForm + "') in app/src/main/res/values/strings.xml");
}

/**
* Ensures our translations actually have a Theme backing them.
*
* <p>
* Inverse of {@link #testThemesHaveTranslationEntities(String)}, useful if we rename or delete themes.
* </p>
*/
@ParameterizedTest
@MethodSource("getThemeTranslations")
public void testNoLeftoverThemeTranslations(String translationIdentifier) {
List<String> themes = getThemes();

String matchingForm = translationIdentifier
.replaceFirst("^" + Theme.PREFIX, "")
.toUpperCase(Locale.ROOT);
assertContains(themes, matchingForm,
"Translation id '" + translationIdentifier + "' translates a theme that doesn't exist in Theme.java " +
"(" + themes + ")");
}


/**
* Ensures that all specific {@link Melody Melodies} are translatable.
*
* @see Melody
* @see Melody#PREFIX
* @see Melody#all
* @see SettingsFragment#loadMelodies()
*/
@ParameterizedTest
@MethodSource("getMelodies")
public void testMelodiesHaveTranslationEntities(String melodyId) {
List<String> translatables = getMelodyTranslations();

String matchingForm = Melody.PREFIX + melodyId.toLowerCase(Locale.ROOT);
assertContains(translatables, matchingForm,
"Melody '" + melodyId + "' has no translation string ('" + matchingForm + "') in app/src/main/res/values/strings.xml");
}


/**
* Ensures our translations actually have a melody backing them.
*
* <p>
* Inverse of {@link #testMelodiesHaveTranslationEntities(String)}, useful if we rename or delete melodies.
* </p>
*/
@ParameterizedTest
@MethodSource("getMelodyTranslations")
public void testNoLeftoverMelodyTranslations(String translationIdentifier) {
List<String> melodies = getMelodies();

String matchingForm = translationIdentifier
.replaceFirst("^" + Melody.PREFIX, "");

assertContains(melodies, matchingForm,
"Translation id '" + translationIdentifier + "' translates a melody that doesn't exist in Melody.java " +
"(" + melodies + ")");
}

/**
* Scans the primary translation string source for identifiers starting with <code>prefix</code>
*/
private static List<String> getTranslationsByPrefix(String prefix) {
// All String-resource identifiers
Field[] allStrings = R.string.class.getFields();

return Arrays.stream(allStrings)
.map(Field::getName)
.filter(name -> name.startsWith(prefix))
.collect(Collectors.toList());
}

/**
* {@link MethodSource} for all soundset translation identifiers
*/
public static List<String> getSoundSetTranslations() {
return getTranslationsByPrefix(SoundSet.PREFIX);
}

/**
* {@link MethodSource} for all soundset asset folders
*/
@NotNull
public static List<Path> getSoundSets() throws IOException {
Path soundAssets = Paths.get("src/main/assets/sounds"); // app-tests run in app-folder (at least on my IDE)

try (Stream<Path> pathStream = Files.list(soundAssets)) {
return pathStream
.filter(Files::isDirectory) // skip top-level files (specifically: "source" attribution file)
.filter(path -> path.getFileName().toString().startsWith(SoundSet.PREFIX))
.collect(Collectors.toList());
}
}

/**
* {@link MethodSource} for all theme translation identifiers
*/
public static List<String> getThemeTranslations() {
return getTranslationsByPrefix(Theme.PREFIX);
}

/**
* {@link MethodSource} for all themes known to PianOli
*
* @see Theme
*/
public static List<String> getThemes() {
Field[] fields = Theme.class.getDeclaredFields();
return Arrays.stream(fields)
.filter(field -> Theme.class.equals(field.getType()))
.filter(field -> Modifier.isStatic(field.getModifiers()))
.map(Field::getName)
.collect(Collectors.toList());
}


/**
* {@link MethodSource} for all melody translation identifiers
*/
public static List<String> getMelodyTranslations() {
return getTranslationsByPrefix(Melody.PREFIX);
}

public static List<String> getMelodies() {
return Arrays.stream(Melody.all)
.map(Melody::getId)
.collect(Collectors.toList());
}
}
Loading