diff -u -r otoku11/tests/core.test.mjs otoku121/tests/core.test.mjs --- otoku11/tests/core.test.mjs 2026-11-16 00:44:48.758384924 +0900 +++ otoku121/tests/core.test.mjs 2026-11-16 01:22:29.871656170 +0900 @@ -35,7 +35,9 @@ test('summary includes both values and basis',()=>{const e=EXAMPLES.rice,s=summaryText(compareProducts(e.a,e.b,e.mode));assert.match(s,/A: 74円/);assert.match(s,/B: 79.6円/);assert.match(s,/100g/);}); test('large valid values remain finite',()=>{const r=compareProducts(product(10000000,1000000,'kg',10000),product(9999999,999999,'g',9999),'weight');assert.ok(Number.isFinite(r.a.unitPrice));assert.ok(Number.isFinite(r.savingPercent));}); test('frontend does not call external APIs or store inputs',()=>{const s=readFileSync(new URL('../dist/otoku/app.js',import.meta.url),'utf8');assert.doesNotMatch(s,/\bfetch\s*\(|XMLHttpRequest|localStorage|sessionStorage|eval\s*\(/);}); -test('assets are relative for subpath deployment',()=>{const s=readFileSync(new URL('../dist/otoku/index.html',import.meta.url),'utf8');assert.match(s,/src="\.\/app.js"/);assert.match(s,/href="\.\/style.css"/);assert.doesNotMatch(s,/src="\//);}); +test('assets are relative for subpath deployment',()=>{const s=readFileSync(new URL('../dist/otoku/index.html',import.meta.url),'utf8');assert.match(s,/src="\.\/app\.js\?v=\d+\.\d+\.\d+"/);assert.match(s,/href="\.\/style\.css\?v=\d+\.\d+\.\d+"/);assert.doesNotMatch(s,/src="\//);}); test('modes use only supported positive scales',()=>{for(const mode of Object.values(MODES))assert.ok(Object.values(mode.units).every(x=>x>0));}); test('percentLabel caps huge values', () => { assert.equal(percentLabel(999.94), '999.9%'); assert.equal(percentLabel(1000), '1,000%以上'); assert.equal(percentLabel(5e13), '1,000%以上'); assert.equal(percentLabel(0.05), '0.1%未満'); }); + +test('asset version strings are consistent', () => { const rd=f=>readFileSync(new URL('../dist/otoku/'+f,import.meta.url),'utf8'); const v=rd('core.js').match(/VERSION = '([\d.]+)'/)[1]; const html=rd('index.html'); for (const a of ['style.css','style-v11.css','app.js']) assert.ok(html.includes(`./${a}?v=${v}`), a); assert.ok(html.includes(`v${v} ·`)); assert.ok(rd('app.js').includes(`from './core.js?v=${v}'`)); assert.ok(rd('sw.js').includes(`const VERSION='${v}'`)); assert.doesNotMatch(rd('sw.js'), /ignoreSearch/); }); diff -u -r otoku11/tests/multi.test.mjs otoku121/tests/multi.test.mjs --- otoku11/tests/multi.test.mjs 2026-11-15 22:06:40.249741877 +0900 +++ otoku121/tests/multi.test.mjs 2026-11-16 01:57:32.554371745 +0900 @@ -15,7 +15,7 @@ test('overBestPercent computed',()=>{const m=compareMany([P(100,100),P(150,100)],'weight');assert.equal(m.items[1].overBestPercent,50);assert.equal(m.items[0].overBestPercent,0);}); test('summary lists ranking and reversal line',()=>{const e=EXAMPLES.yogurt,s=summaryText(compareMany(e.items,e.mode));assert.match(s,/A: .*C: .*B: /);assert.match(s,/限らない/);}); test('precision distinguishes close values',()=>{const p=precisionFor([1.001,1.002,1.003]);assert.ok(p>=3);}); -test('pwa assets are relative and cached',()=>{const sw=readFileSync(new URL('../dist/otoku/sw.js',import.meta.url),'utf8');for(const f of ['./app.js','./core.js','./style-v11.css','./index.html'])assert.ok(sw.includes(`'${f}'`));const man=JSON.parse(readFileSync(new URL('../dist/otoku/manifest.webmanifest',import.meta.url),'utf8'));assert.equal(man.start_url,'./');}); +test('pwa assets are relative and cached',()=>{const sw=readFileSync(new URL('../dist/otoku/sw.js',import.meta.url),'utf8');const v=sw.match(/const VERSION='([\d.]+)'/)[1];for(const f of ['./app.js','./core.js','./style-v11.css','./style.css'])assert.ok(sw.includes(`"${f}?v=${v}"`),f);assert.ok(sw.includes(`"./index.html"`));const man=JSON.parse(readFileSync(new URL('../dist/otoku/manifest.webmanifest',import.meta.url),'utf8'));assert.equal(man.start_url,'./');}); test('share falls back without Web Share',()=>{const s=readFileSync(new URL('../dist/otoku/app.js',import.meta.url),'utf8');assert.match(s,/canShare/);assert.match(s,/clipboard\.writeText/);}); import {isSampleRemaining,formatMoney,validateProduct,SAMPLE_NOTE} from '../dist/otoku/core.js'; const J=EXAMPLES.juice; @@ -35,3 +35,6 @@ test('truly replaced values drop the note',()=>assert.equal(isSampleRemaining([{price:'120.00',amount:'600',unit:'mL',packs:'1'},{price:'300',amount:'2',unit:'L',packs:'1'}],J),false)); test('rice truly replaced drops the note',()=>assert.equal(isSampleRemaining([{price:'1500',amount:'2.5',unit:'kg',packs:'1'},{price:'3900',amount:'6',unit:'kg',packs:'1'}],R),false)); test('no network claim remains in UI text',()=>{for(const f of ['index.html','manifest.webmanifest'])assert.doesNotMatch(readFileSync(new URL(`../dist/otoku/${f}`,import.meta.url),'utf8'),/電波/);}); + +import {createHash} from 'node:crypto'; +test('sw.js hashes match the shipped files', () => { const dir=new URL('../dist/otoku/',import.meta.url); const sw=readFileSync(new URL('sw.js',dir),'utf8'); const H=JSON.parse(sw.match(/const HASH=(\{[\s\S]*?\});/)[1]); assert.equal(Object.keys(H).length,7); for (const [k,h] of Object.entries(H)) { const f=k.replace('./','').split('?')[0]; assert.equal(createHash('sha256').update(readFileSync(new URL(f,dir))).digest('hex'),h,k); } assert.doesNotMatch(sw,/ignoreSearch/); assert.doesNotMatch(sw.split("addEventListener('fetch'")[1],/\.put\(/); });