#!/usr/bin/env python3 """Test actual MusicLibrary filename resolution with host Android stubs (JDK 17+). Optionally pass a UTF-8 CUE file to test it against an NFD audio filename too. """ from pathlib import Path import subprocess import sys import tempfile repo = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory(prefix='loopdeck-cue-') as temp: root = Path(temp) def stub(name, body, base=''): package, cls = name.rsplit('/', 1) path = root / (name + '.java') path.parent.mkdir(parents=True, exist_ok=True) path.write_text('package ' + package.replace('/', '.') + '; public class ' + cls + base + ' {' + body + '}', encoding='utf-8') stub('android/net/Uri', 'final String id; public Uri(String s){id=s;} public String toString(){return id;}') stub('android/os/ParcelFileDescriptor', 'public int getFd(){return 1;} public void close(){}', ' implements AutoCloseable') stub('android/database/Cursor', 'public boolean moveToNext(){return false;} public String getString(int n){return null;} public void close(){}', ' implements AutoCloseable') stub('android/content/Context', 'public final ContentResolver resolver=new ContentResolver(); public ContentResolver getContentResolver(){return resolver;}') stub('android/content/ContentResolver', ''' public String cue; public android.database.Cursor query(android.net.Uri u,String[] c,String a,String[] b,String d){return new android.database.Cursor();} public java.io.InputStream openInputStream(android.net.Uri u){return new java.io.ByteArrayInputStream(cue.getBytes(java.nio.charset.StandardCharsets.UTF_8));} public android.os.ParcelFileDescriptor openFileDescriptor(android.net.Uri u,String m){return new android.os.ParcelFileDescriptor();} ''') stub('android/provider/DocumentsContract', ''' public static android.net.Uri buildChildDocumentsUriUsingTree(android.net.Uri u,String s){return new android.net.Uri(s);} public static android.net.Uri buildDocumentUriUsingTree(android.net.Uri u,String s){return new android.net.Uri(s);} public static class Document {public static final String COLUMN_DOCUMENT_ID="id",COLUMN_DISPLAY_NAME="name",COLUMN_MIME_TYPE="mime",MIME_TYPE_DIR="dir";} ''') stub('net/astropenguin/loopdeck/NativeDecoder', ''' static long open(int fd){return 1;} static void close(long h){} static String cueSheet(long h){return "";} static String[] chapters(long h){return new String[0];} static long duration(long h){return 120000;} ''') stub('net/astropenguin/loopdeck/FilenameTest', r''' static void expect(boolean ok){if(!ok)throw new AssertionError();} static MusicLibrary.Entry entry(String id,String name){return new MusicLibrary.Entry(id,name,"application/octet-stream",new android.net.Uri(id));} static String sheet(String name){return "\ufeffFILE \""+name+"\" WAVE\r\nTRACK 01 AUDIO\r\nINDEX 01 00:00:00\r\nTRACK 02 AUDIO\r\nINDEX 01 01:00:00\r\n";} static void rejected(android.content.Context context,MusicLibrary.Entry cue,java.util.List folder)throws Exception { try{MusicLibrary.tracks(context,cue,folder);throw new AssertionError("accepted unresolved sheet");} catch(java.io.IOException expected){} } public static void main(String[] args)throws Exception { android.content.Context context=new android.content.Context(); String nfc="聖骸メロフォビア.tta", nfd=java.text.Normalizer.normalize(nfc,java.text.Normalizer.Form.NFD); expect(!nfc.equals(nfd)); MusicLibrary.Entry cue=entry("cue","album.cue"), audio=entry("audio",nfd); context.resolver.cue=sheet(nfc); java.util.List tracks=MusicLibrary.tracks(context,cue,java.util.List.of(cue,audio)); expect(tracks.size()==2 && tracks.get(0).uri==audio.uri && tracks.get(0).endMs==60000); expect(MusicLibrary.tracks(context,audio,java.util.List.of(cue,audio)).size()==2); // Also support decomposed FILE references and composed storage names. context.resolver.cue=sheet(nfd);audio=entry("composed",nfc); expect(MusicLibrary.tracks(context,cue,java.util.List.of(cue,audio)).size()==2); // Existing literal matches win when both spellings exist. MusicLibrary.Entry decomposed=entry("decomposed",nfd); expect(MusicLibrary.tracks(context,cue,java.util.List.of(cue,audio,decomposed)).get(0).uri==decomposed.uri); // Missing files and multiple fallback matches must still fail. context.resolver.cue=sheet(nfc); rejected(context,cue,java.util.List.of(cue,entry("wrong","other.tta"))); rejected(context,cue,java.util.List.of(cue,decomposed,entry("duplicate",nfd))); context.resolver.cue=sheet("./ALBUM.TTA"); expect(MusicLibrary.tracks(context,cue,java.util.List.of(cue,entry("ascii","album.tta"))).size()==2); // Canonical equivalence only: fullwidth ASCII is a distinct filename. context.resolver.cue=sheet("A.tta"); rejected(context,cue,java.util.List.of(cue,entry("ascii","A.tta"))); if(args.length>0){ context.resolver.cue=java.nio.file.Files.readString(java.nio.file.Path.of(args[0])); java.util.List parts=CueSheet.parse(context.resolver.cue); expect(!parts.isEmpty()); String filename=java.text.Normalizer.normalize(parts.get(0).file,java.text.Normalizer.Form.NFD); MusicLibrary.Entry image=entry("uploaded-audio",filename); tracks=MusicLibrary.tracks(context,cue,java.util.List.of(cue,image)); expect(tracks.size()==parts.size()); for(int i=0;i