Skip to content

Commit

Permalink
Merge pull request #88 from juleskers/vader-jakob
Browse files Browse the repository at this point in the history
Consistency checks for dynamic strings, before adding "Frère Jacques" song.
  • Loading branch information
pserwylo authored Jan 18, 2024
2 parents 216017b + 63d4078 commit 42df804
Show file tree
Hide file tree
Showing 9 changed files with 389 additions and 7 deletions.
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

0 comments on commit 42df804

Please sign in to comment.