2026-10-04 19:22:31 +08:00
const test = require ( 'node:test' ), assert = require ( 'node:assert/strict' ), crypto = require ( 'node:crypto' ), fs = require ( 'node:fs' );
const hash = require ( './web/file-hash.js' );
test ( 'upload MD5 matches RFC vectors and a multi-block file' ,()=>{
for ( const text of [ '' , 'a' , 'abc' , 'message digest' , 'abcdefghijklmnopqrstuvwxyz' , '照片与语音' , 'x' . repeat ( 4194304 )]){
const bytes = Buffer . from ( text ); assert . equal ( hash . md5 ( bytes ), crypto . createHash ( 'md5' ). update ( bytes ). digest ( 'hex' ));
}
});
test ( 'browser hashes in 4 MiB slices rather than loading the entire video' , async ()=>{
const file = new Blob ([ Buffer . alloc ( 4194304 , 1 ), Buffer . from ( 'tail' )]); const values = await hash . blocks ( file );
assert . equal ( values . length , 2 ); assert . equal ( values [ 1 ], crypto . createHash ( 'md5' ). update ( 'tail' ). digest ( 'hex' ));
await assert . rejects ( hash . blocks ( file ,()=>{},()=> false ), /取消/ );
});
test ( 'journals default to private and encode content before rendering' ,()=>{
const html = fs . readFileSync ( 'web/index.html' , 'utf8' ), js = fs . readFileSync ( 'web/journal.js' , 'utf8' );
assert . match ( html , /<select name="visibility"><option value="private">/ ); assert . match ( js , /esc\(e.text\)/ ); assert . match ( js , /esc\(c.text\)/ );
assert . doesNotMatch ( js , /localStorage|indexedDB|caches\.open|access_token|secretKey.*localStorage/ );
});
test ( 'leave clears media URLs, live microphone tracks, and pending requests' ,()=>{
2026-10-04 20:03:44 +08:00
const js = fs . readFileSync ( 'web/journal.js' , 'utf8' ); assert . match ( js , /journalRequest\?\.abort\(\)/ ); assert . match ( js , /releaseJournalMedia/ ); assert . match ( js , /removeAttribute\('src'\)/ ); assert . match ( js , /getTracks\(\).forEach\(t=>t.stop\(\)\)/ );
});
test ( 'photos load by default and audio/video preload metadata with controls, never autoplay' ,()=>{
const vm = require ( 'node:vm' ), js = fs . readFileSync ( 'web/journal.js' , 'utf8' );
const code = js . slice ( js . indexOf ( 'function journalMediaView(' ), js . indexOf ( 'function bindJournalMedia(' ));
const c = vm . createContext ({ esc : s => String ( s ). replaceAll ( '<' , '<' ), journalMediaUrl : id => '/media/journal?id=' + id }); vm . runInContext ( code , c );
for ( const mime of [ 'image/png' , 'audio/webm' , 'video/mp4' ]){
const html = c . journalMediaView ({ id : 'a' , name : '<test>' , mime }); assert . match ( html , /src="\/media\/journal\?id=a"/ ); assert . doesNotMatch ( html , /autoplay|<test>/ );
if ( mime . startsWith ( 'image' )) assert . match ( html , /loading="lazy"/ ); else assert . match ( html , /controls preload="metadata"/ );
}
});
test ( 'successful save clears draft attachment ids, stops recorder, and reloads feed' ,()=>{
const js = fs . readFileSync ( 'web/journal.js' , 'utf8' );
const fn = js . slice ( js . indexOf ( 'function newJournal' ), js . indexOf ( 'async function loadJournal' ));
assert . match ( fn , /journalDraftMedia.clear\(\)/ ); assert . match ( fn , /journalSelectedMedia.clear\(\)/ ); assert . match ( fn , /stopJournalVoice\(\)/ ); assert . match ( fn , /journal-library'\).open=false/ );
2026-10-04 20:37:14 +08:00
assert . match ( js , /newJournal\(\);resetJournalFilters\('mine'\);await loadJournal\(saved.id\)/ );
2026-10-04 20:03:44 +08:00
assert . match ( js , /available.filter\(m=>journalDraftMedia.has\(m.id\)\)/ );
});
test ( 'attachment preview uses a closable dialog and deletion has a restore path' ,()=>{
const js = fs . readFileSync ( 'web/journal.js' , 'utf8' ), html = fs . readFileSync ( 'web/index.html' , 'utf8' );
assert . match ( html , /<dialog id="journal-preview"/ ); assert . match ( js , /addEventListener\('close'/ ); assert . match ( js , /\/api\/journal\/restore-upload/ );
assert . match ( js , /journal-library-list/ ); assert . match ( js , /if\(other!==media\)other.pause\(\)/ );
});
test ( 'structured place formatting and search preserve separate ancestral/birth/legacy locations' ,()=>{
const vm = require ( 'node:vm' ), js = fs . readFileSync ( 'web/family.js' , 'utf8' );
2026-10-04 21:30:30 +08:00
const c = vm . createContext ({ ZhaoGeography : require ( './web/geography.js' )}); vm . runInContext ( js . slice ( js . indexOf ( 'function personLocation' ), js . indexOf ( 'function personName' )), c );
2026-10-04 20:03:44 +08:00
const person = { nativeProvince : '山东省' , nativeCity : '烟台市' , nativeDistrict : '莱阳市' , birthCity : '济南市' , birthplace : '旧村名' };
assert . equal ( c . personLocation ( person , 'native' ), '山东省 · 烟台市 · 莱阳市' ); assert . equal ( c . personLocation ( person , 'birth' ), '济南市' ); assert . ok ( c . personSearch ( person ). includes ( '旧村名' ));
2026-10-04 19:22:31 +08:00
});
test ( 'new entry clears hidden edit identifiers even when form.reset preserves them' ,()=>{
const vm = require ( 'node:vm' ), js = fs . readFileSync ( 'web/journal.js' , 'utf8' );
const form = { elements : { id : { value : 'old-entry' }, revision : { value : '9' }, clientId : { value : 'old-request' }}, reset (){}};
const context = { $ : ()=> form }; vm . createContext ( context );
vm . runInContext ( js . match ( /function clearJournalForm\(\)\{[^\n]+/ )[ 0 ] + '\nclearJournalForm();' , context );
for ( const field of Object . values ( form . elements )) assert . equal ( field . value , '' );
assert . match ( js , /function newJournal\(\)\{clearJournalForm\(\)/ );
assert . match ( js , /function resetJournal\(\).*clearJournalForm\(\)/ );
});
test ( 'initial composer setup happens before asynchronous loading and never clears a typed draft' ,()=>{
const js = fs . readFileSync ( 'web/journal.js' , 'utf8' ); const fn = js . slice ( js . indexOf ( 'async function loadJournal' ), js . indexOf ( 'function renderJournalUploads' ));
assert . ok ( fn . indexOf ( 'newJournal()' ) < fn . indexOf ( 'await api(' )); assert . equal ( fn . match ( /newJournal\(\)/g ). length , 1 );
});