MP4: Add test coverage for chapter unicode, empty titles, and format independence

Six new tests exercise corners of the chapter implementation that the
orphaned-mdat fix did not reach:

testQTChapterListUnicodeTitles / testChapterListUnicodeTitles --
Round-trip Japanese, German (umlaut), and Russian titles through the
QT text-sample serialisation and the Nero length-prefixed UTF-8 path
respectively.  These are separate paths in the code and benefit from
separate coverage.

testQTChapterListEmptyTitleStripped --
A multi-chapter list whose first entry is empty at t=0 matches the QT
dummy-marker pattern; read() must drop it.  Test documents the rule so
a regression is immediately detectable.

testQTChapterListSingleEmptyTitleNotStripped --
The stripping rule only applies when size > 1.  A single empty-title
chapter at t=0 is valid and must be preserved.

testNeroAndQTChaptersAreIndependent --
Both formats can coexist; removing one leaves the other intact.
Validates the lazy saveChaptersIfModified contract in mp4file.cpp.

testNeroChaptersAloneWhenNoQT --
Writing one format must not create atoms for the other.

All 47 MP4 tests pass.
This commit is contained in:
Ryan Francesconi
2026-04-23 12:19:27 -07:00
parent 85b6a9eb93
commit 05c2c8671e

View File

@@ -115,6 +115,12 @@ class TestMP4 : public CppUnit::TestFixture
CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter);
CPPUNIT_TEST(testQTChapterListNoOrphanedMdat);
CPPUNIT_TEST(testQTChapterListSharedMdatPreservesAudio);
CPPUNIT_TEST(testQTChapterListUnicodeTitles);
CPPUNIT_TEST(testChapterListUnicodeTitles);
CPPUNIT_TEST(testQTChapterListEmptyTitleStripped);
CPPUNIT_TEST(testQTChapterListSingleEmptyTitleNotStripped);
CPPUNIT_TEST(testNeroAndQTChaptersAreIndependent);
CPPUNIT_TEST(testNeroChaptersAloneWhenNoQT);
CPPUNIT_TEST_SUITE_END();
public:
@@ -1403,6 +1409,231 @@ public:
}
}
// Unicode titles (CJK, Latin with diacritics, Cyrillic) survive the
// write -> save -> open -> read round-trip through the QT chapter track.
// This exercises the text-sample serialisation in mp4qtchapterlist.cpp.
void testQTChapterListUnicodeTitles()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// UTF-8: 日本語, Über, Привет
const String japanese("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e", String::UTF8);
const String german("\xc3\x9c" "ber", String::UTF8);
const String russian("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", String::UTF8);
{
MP4::File f(filename.c_str());
f.setQtChapters(MP4::ChapterList{
MP4::Chapter(japanese, 0),
MP4::Chapter(german, 15000LL),
MP4::Chapter(russian, 30000LL)
});
CPPUNIT_ASSERT(f.save());
}
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
CPPUNIT_ASSERT_EQUAL(japanese, chapters[0].title());
CPPUNIT_ASSERT_EQUAL(german, chapters[1].title());
CPPUNIT_ASSERT_EQUAL(russian, chapters[2].title());
}
}
// Unicode titles survive the write -> save -> open -> read round-trip
// through the Nero chpl atom, which uses a different serialisation path
// (length-prefixed UTF-8 inside udta/chpl).
void testChapterListUnicodeTitles()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// UTF-8: 日本語, Über, Привет
const String japanese("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e", String::UTF8);
const String german("\xc3\x9c" "ber", String::UTF8);
const String russian("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", String::UTF8);
{
MP4::File f(filename.c_str());
f.setNeroChapters(MP4::ChapterList{
MP4::Chapter(japanese, 0),
MP4::Chapter(german, 15000LL),
MP4::Chapter(russian, 30000LL)
});
CPPUNIT_ASSERT(f.save());
}
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.neroChapters();
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
CPPUNIT_ASSERT_EQUAL(japanese, chapters[0].title());
CPPUNIT_ASSERT_EQUAL(german, chapters[1].title());
CPPUNIT_ASSERT_EQUAL(russian, chapters[2].title());
}
}
// When a multi-chapter list begins with an empty-titled chapter at time 0,
// that entry matches the QT dummy-marker pattern and must be stripped on
// read-back. This test documents the stripping behaviour so a regression
// is immediately detectable.
void testQTChapterListEmptyTitleStripped()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
{
MP4::File f(filename.c_str());
// First entry has an empty title at t=0. write() sees the list already
// starts at t=0 so no dummy is prepended; the empty entry is written
// as-is. read() must strip it because size > 1 && startTime()==0 &&
// title().isEmpty().
f.setQtChapters(MP4::ChapterList{
MP4::Chapter("", 0),
MP4::Chapter("Chapter 1", 5000LL),
MP4::Chapter("Chapter 2", 10000LL)
});
CPPUNIT_ASSERT(f.save());
}
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
// The empty t=0 entry is stripped; only the two real chapters remain.
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
CPPUNIT_ASSERT_EQUAL(5000LL, chapters[0].startTime());
CPPUNIT_ASSERT_EQUAL(String("Chapter 1"), chapters[0].title());
CPPUNIT_ASSERT_EQUAL(10000LL, chapters[1].startTime());
CPPUNIT_ASSERT_EQUAL(String("Chapter 2"), chapters[1].title());
}
}
// A single chapter with an empty title at time 0 must NOT be stripped.
// The stripping rule applies only when size > 1 -- a file with exactly one
// chapter is valid and its t=0 marker is not a dummy.
void testQTChapterListSingleEmptyTitleNotStripped()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
{
MP4::File f(filename.c_str());
f.setQtChapters(MP4::ChapterList{
MP4::Chapter("", 0)
});
CPPUNIT_ASSERT(f.save());
}
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime());
CPPUNIT_ASSERT_EQUAL(String(""), chapters[0].title());
}
}
// Both Nero (chpl) and QT chapter tracks can coexist in the same file.
// Writing one format must not disturb the other, and removing one must
// leave the other intact -- this validates the saveChaptersIfModified lazy
// save contract in mp4file.cpp.
void testNeroAndQTChaptersAreIndependent()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write both formats in a single save.
{
MP4::File f(filename.c_str());
f.setNeroChapters(MP4::ChapterList{
MP4::Chapter("Nero 1", 0),
MP4::Chapter("Nero 2", 10000LL)
});
f.setQtChapters(MP4::ChapterList{
MP4::Chapter("QT 1", 0),
MP4::Chapter("QT 2", 20000LL)
});
CPPUNIT_ASSERT(f.save());
}
// Verify both are present and distinct.
{
MP4::File f(filename.c_str());
const MP4::ChapterList nero = f.neroChapters();
const MP4::ChapterList qt = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(2U, nero.size());
CPPUNIT_ASSERT_EQUAL(String("Nero 1"), nero[0].title());
CPPUNIT_ASSERT_EQUAL(String("Nero 2"), nero[1].title());
CPPUNIT_ASSERT_EQUAL(2U, qt.size());
CPPUNIT_ASSERT_EQUAL(String("QT 1"), qt[0].title());
CPPUNIT_ASSERT_EQUAL(String("QT 2"), qt[1].title());
// Remove only the QT track.
f.setQtChapters(MP4::ChapterList());
CPPUNIT_ASSERT(f.save());
}
// QT removed; Nero chapters must be fully intact.
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
const MP4::ChapterList nero = f.neroChapters();
CPPUNIT_ASSERT_EQUAL(2U, nero.size());
CPPUNIT_ASSERT_EQUAL(String("Nero 1"), nero[0].title());
CPPUNIT_ASSERT_EQUAL(String("Nero 2"), nero[1].title());
}
}
// Writing only Nero chapters must not accidentally create a QT chapter track,
// and writing only QT chapters must not accidentally create a Nero chpl atom.
void testNeroChaptersAloneWhenNoQT()
{
// Nero only -- QT track must remain absent.
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
{
MP4::File f(filename.c_str());
f.setNeroChapters(MP4::ChapterList{
MP4::Chapter("Nero Only", 0)
});
CPPUNIT_ASSERT(f.save());
}
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT_EQUAL(1U, f.neroChapters().size());
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
}
}
// QT only -- Nero chpl atom must remain absent.
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
{
MP4::File f(filename.c_str());
f.setQtChapters(MP4::ChapterList{
MP4::Chapter("QT Only", 0)
});
CPPUNIT_ASSERT(f.save());
}
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT_EQUAL(1U, f.qtChapters().size());
CPPUNIT_ASSERT(f.neroChapters().isEmpty());
}
}
}
};
CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4);