以可執行範例測試 JPG 檔名碰撞處理規則
Test a JPG Name Collision Policy With Executable Examples
JPG 檔名碰撞配置規則以 NFC 正規化及小寫作比較鍵,遇上重名時保留檔名主幹並加上 -2、-3 等後綴。Node v22.23.4 的合成測試涵蓋 5 組有效清單、1 次全新呼叫重複檢查及 9 個拒絕輸入,並以退出碼 0 完成。測試只檢查候選檔名,不涉及真實圖片轉換、ZIP 建立或檔案系統。
Two successful results can end up with the same output name. If a ZIP builder stores entries under those names as object keys, a later entry can replace an earlier one before the archive is written. Resolving the names first makes the collision visible and gives both results distinct names within that allocation.
The allocator below is an educational policy, not the implementation of a product. Its input is an array of JPG leaf candidates after conversion, such as scan.jpg, not arbitrary source paths or source extensions. It only allocates names. It doesn't decode images or write archives.
Allocate within one run
The comparison key is NFC-normalized and lowercased. A noncolliding candidate is returned unchanged. A collision keeps the stem's spelling and adds -N.jpg with a lowercase extension, so scan.JPG can become scan-2.jpg. Suffixes start at -2, then -3, and so on, and an already occupied suffix is skipped. The set is local to the call, so each new call starts a fresh allocation.
export function allocateJpgNames(names) {
if (!Array.isArray(names)) throw new TypeError('names must be an array');
const taken = new Set();
const key = name => name.normalize('NFC').toLowerCase();
return names.map(name => {
if (typeof name !== 'string' || !/\.jpg$/i.test(name) ||
name.length <= 4 || /[\\/]/.test(name) || name.includes(String.fromCharCode(0))) {
throw new TypeError('expected a JPG leaf candidate');
}
const stem = name.slice(0, -4);
let candidate = name;
let suffix = 2;
while (taken.has(key(candidate))) candidate = `${stem}-${suffix++}.jpg`;
taken.add(key(candidate));
return candidate;
});
}
Exercise the contract
Put both blocks, in order, in one .mjs file and run it with Node. The second block keeps the assertions from the supplied test. Only the allocator import was removed, because the function is in the same file.
import assert from 'node:assert/strict';
const cases = [
[['scan.jpg','scan.jpg'],['scan.jpg','scan-2.jpg']],
[['Scan.jpg','scan.JPG'],['Scan.jpg','scan-2.jpg']],
[['caf\u00e9.jpg','cafe\u0301.jpg'],['caf\u00e9.jpg','cafe\u0301-2.jpg']],
[['scan.jpg','scan-2.jpg','scan.jpg'],['scan.jpg','scan-2.jpg','scan-3.jpg']],
[[],[]]
];
for(const [input,expected] of cases) {
const saved=[...input];assert.deepEqual(allocateJpgNames(input),expected);assert.deepEqual(input,saved);
}
assert.deepEqual(allocateJpgNames(['scan.jpg','scan.jpg']),allocateJpgNames(['scan.jpg','scan.jpg']));
for(const invalid of [null,'a.jpg',[null],[''],['.jpg'],['a.png'],['dir/a.jpg'],['dir\\a.jpg'],['a\0.jpg']]) assert.throws(()=>allocateJpgNames(invalid));
console.log(JSON.stringify({valid_cases:cases.length,fresh_run_repeat:1,reject_cases:9,boundary:'synthetic candidate names only; no image/ZIP/filesystem operation'}));
The five valid lists cover an exact duplicate, case differences, canonically equivalent Unicode spellings, an occupied suffix, and an empty list. Each one also checks that the input array is unchanged. The repeat assertion compares two fresh calls; it doesn't simulate concurrent calls or persistent allocation. The nine invalid inputs exercise the function's stated input checks, including non-arrays, non-JPG candidates, separators, and NUL.
The retained run on October 31, 2026 used Node v22.23.4 and exited with code 0. Its output reported valid_cases: 5, fresh_run_repeat: 1, and reject_cases: 9. A red stub run exited 1 with an assertion failure. These are synthetic naming checks, not a conversion or ZIP test.
Keep identity separate from a name
Take two source records with different IDs that both yield scan.jpg. Their allocated names may be scan.jpg and scan-2.jpg, but the suffix encodes neither source ID. Keep a separate mapping from each source ID (and, where relevant, page) to its allocated output. A fresh run or a different candidate order can change that mapping. A name is not a content digest either.
NFC handles canonical equivalence; it is not NFKC, and JavaScript lowercasing is not full Unicode case folding. The comparison policy doesn't guarantee that every extraction filesystem will agree. The input checks also don't make this a secure, portable filename sanitizer. For example, they don't cover every reserved name or character. See MDN's String.prototype.normalize() reference for the normalization forms.
Disclosure: This article was drafted with GPT-6.1 Terra for Yan Wang / LensUp; Yan Wang is affiliated with LensUp. The text was polished by an independent editorial AI and fact-checked by Codex, with an independent code review. No human editor or owner reviewed or tested it. The evidence is source and public-page inspection and the retained synthetic Node checks, dated October 31, 2026. The Node checks covered synthetic JPG candidate names only: 5 valid lists including an empty list, 1 fresh-run repeat check, and 9 rejected inputs (Node v22.23.4, exit 0). No real image conversion, TIFF decoding, ZIP creation, filesystem, or device test was performed, and these checks do not establish product-function conformance.
來源:DEV Community · dev.to