diff --git a/apps/internal-site/components/web-uploader/source-scanner.test.ts b/apps/internal-site/components/web-uploader/source-scanner.test.ts index 7ed1082..d9e2f57 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.test.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.test.ts @@ -65,6 +65,77 @@ describe("scanBrowserUploadFiles", () => { expect(plan.frames[0].misc).toHaveLength(1); }); + it("uses the first after label as the default heatmap reference for src/rip/flt sets", () => { + const plan = scanBrowserUploadFiles( + [ + image("case/src/001.png"), + image("case/rip/001.png"), + image("case/flt/001_crop.png"), + ], + "case", + ); + + expect(plan.frames).toHaveLength(1); + expect(plan.frames[0].after.label).toBe("Rip"); + expect(plan.heatmapReferenceLabel).toBe("Rip"); + }); + + it("uses a common comparison label when the first frame after label is not shared", () => { + const plan = scanBrowserUploadFiles( + [ + image("sample/frame-001_src.png"), + image("sample/frame-001_output.png"), + image("sample/frame-001_rip.png"), + image("sample/frame-002_src.png"), + image("sample/frame-002_rip.png"), + ], + "sample", + ); + + expect(plan.frames.map((frame) => frame.after.label)).toEqual(["After", "Rip"]); + expect(plan.frames[0].misc.map((asset) => asset.label)).toEqual(["Rip"]); + expect(plan.heatmapReferenceLabel).toBe("Rip"); + expect(plan.issues).toEqual([]); + }); + + it("reports an error when no heatmap reference label is shared by every frame", () => { + const plan = scanBrowserUploadFiles( + [ + image("sample/frame-001_src.png"), + image("sample/frame-001_output.png"), + image("sample/frame-002_src.png"), + image("sample/frame-002_rip.png"), + ], + "sample", + ); + + expect(plan.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + severity: "error", + code: "heatmap-reference-missing", + }), + ]), + ); + }); + + it("preserves nested out and output directory variants for primary selection", () => { + const plan = scanBrowserUploadFiles( + [ + image("case/src/001.png"), + image("case/output/001.png"), + image("case/out/001.png"), + ], + "case", + ); + + expect(plan.frames).toHaveLength(1); + expect(plan.frames[0].after.source.relativePath).toBe("out/001.png"); + expect(plan.frames[0].misc.map((asset) => asset.source.relativePath)).toEqual([ + "output/001.png", + ]); + }); + it("reports an error when a before file has no matching after file", () => { const plan = scanBrowserUploadFiles( [image("before/001.png"), image("before/002.png"), image("after/001.png")], diff --git a/apps/internal-site/components/web-uploader/source-scanner.ts b/apps/internal-site/components/web-uploader/source-scanner.ts index 3725219..f020f0e 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.ts @@ -371,7 +371,7 @@ function assetPlan(kind: WebUploadAssetPlan["kind"], candidate: SourceCandidate) kind === "before" ? "Before" : kind === "after" - ? "After" + ? variantLabel(candidate.variant) || "After" : kind === "heatmap" ? "Heatmap" : variantLabel(candidate.variant) || "Misc"; @@ -548,6 +548,58 @@ function deriveGroupIdentity(sourceRootName: string, candidates: SourceCandidate return { slug, title: titleCase(commonPrefix) || titleCase(sourceRootName) || "Uploaded Group" }; } +function orderedComparisonLabels(frame: WebUploadFramePlan) { + return [frame.after.label, ...frame.misc.map((asset) => asset.label)].filter( + (label, index, labels) => labels.indexOf(label) === index, + ); +} + +/** + * Mirrors the preview selector rule so the generated default is safe for every frame instead of + * only matching the first row's primary comparison column. + */ +function commonHeatmapReferenceLabels(frames: WebUploadFramePlan[]) { + if (frames.length === 0) { + return []; + } + + const [firstFrame, ...remainingFrames] = frames; + const commonLabels = new Set(orderedComparisonLabels(firstFrame)); + for (const frame of remainingFrames) { + const labels = new Set(orderedComparisonLabels(frame)); + for (const label of [...commonLabels]) { + if (!labels.has(label)) { + commonLabels.delete(label); + } + } + } + + return orderedComparisonLabels(firstFrame).filter((label) => commonLabels.has(label)); +} + +export function defaultHeatmapReferenceLabel(frames: WebUploadFramePlan[]) { + return commonHeatmapReferenceLabels(frames)[0] ?? "After"; +} + +/** + * Blocks generation when no global heatmap reference can exist; otherwise asset generation would + * fail later on the first row missing the stored reference label. + */ +function heatmapReferenceIssues(frames: WebUploadFramePlan[]): WebUploadIssue[] { + if (frames.length === 0 || commonHeatmapReferenceLabels(frames).length > 0) { + return []; + } + + return [ + { + code: "heatmap-reference-missing", + severity: "error", + path: frames[0].after.source.relativePath, + message: "没有所有帧都可用的 heatmap 参考列,请统一比较列后再上传。", + }, + ]; +} + function parseEntries(entries: BrowserUploadFile[], variantOverride?: string) { const candidates: SourceCandidate[] = []; const ignored: IgnoredUploadFile[] = []; @@ -601,9 +653,9 @@ function buildFlatPlan(sourceRootName: string, entries: BrowserUploadFile[], ign suggestedGroupSlug: identity.slug, suggestedGroupTitle: identity.title, frames, - heatmapReferenceLabel: "After", + heatmapReferenceLabel: defaultHeatmapReferenceLabel(frames), ignoredFiles: [...ignoredFiles, ...parsed.ignored], - issues, + issues: [...issues, ...heatmapReferenceIssues(frames)], }; } @@ -611,7 +663,13 @@ function buildNestedPlan(sourceRootName: string, entries: BrowserUploadFile[], i const scopedEntries = (directory: string | null) => directory ? entries.filter((entry) => topLevelDirectory(entry.relativePath) === directory) : []; const beforeParsed = parseEntries(scopedEntries(layout.beforeDir), "source"); - const afterParsedResults = layout.afterDirs.map((directory, index) => parseEntries(scopedEntries(directory), index === 0 ? "out" : basename(directory).toLowerCase())); + const afterParsedResults = layout.afterDirs.map((directory) => { + const directoryVariant = basename(directory).toLowerCase(); + return parseEntries( + scopedEntries(directory), + directoryVariant, + ); + }); const heatmapParsedResults = layout.heatmapDirs.map((directory) => parseEntries(scopedEntries(directory), "heatmap")); const miscParsedResults = layout.miscDirs.map((directory) => parseEntries(scopedEntries(directory), basename(directory).toLowerCase() || "misc")); const afterParsed = afterParsedResults.flatMap((result) => result.candidates); @@ -689,9 +747,9 @@ function buildNestedPlan(sourceRootName: string, entries: BrowserUploadFile[], i suggestedGroupSlug: identity.slug, suggestedGroupTitle: identity.title, frames, - heatmapReferenceLabel: "After", + heatmapReferenceLabel: defaultHeatmapReferenceLabel(frames), ignoredFiles: ignored, - issues, + issues: [...issues, ...heatmapReferenceIssues(frames)], }; }