diff --git a/codex/librarian/onlinetag/session_state.py b/codex/librarian/onlinetag/session_state.py
index bf25c4ab5..d66a68c26 100644
--- a/codex/librarian/onlinetag/session_state.py
+++ b/codex/librarian/onlinetag/session_state.py
@@ -215,6 +215,21 @@ def serialize_candidate(c) -> dict[str, Any]:
# The series' ordinal volume. Metron's issue rows carry one;
# Comic Vine's search results have no equivalent.
"volume": getattr(summary, "volume", None),
+ # What tells apart candidates whose covers and titles read
+ # identically: a reprint volume, a foreign or a collected
+ # edition. Comic Vine fills most of these from rows it already
+ # fetched. Metron always has the start year, but gets the
+ # format, publisher and issue count only when titles collide.
+ # Absent before comicbox 5.4, and None wherever a source has
+ # nothing.
+ "series_start_year": getattr(summary, "series_start_year", None),
+ "series_publisher": getattr(summary, "series_publisher", None),
+ "series_issue_count": getattr(summary, "series_issue_count", None),
+ # Publication format, e.g. "Trade Paperback". Metron only.
+ "series_type": getattr(summary, "series_type", None),
+ # The issue's own title and one-line deck. Comic Vine only.
+ "issue_title": getattr(summary, "issue_title", None),
+ "issue_summary": getattr(summary, "issue_summary", None),
},
"score": float(c.score),
# What the blended score is made of. Two candidates can read
diff --git a/frontend/src/components/online-tag/candidate-row.vue b/frontend/src/components/online-tag/candidate-row.vue
index 2936b7c62..e060c0e81 100644
--- a/frontend/src/components/online-tag/candidate-row.vue
+++ b/frontend/src/components/online-tag/candidate-row.vue
@@ -6,6 +6,16 @@
a link to the source's own page, and what the blended score is made
of (#854).
+ Covers are often near-identical too: a reprint volume, a foreign or a
+ collected edition. So two subdued lines under the title carry what the
+ source knows about the series (publisher, format, start year, issue
+ count) and about the issue (its title and one-line deck). Comic Vine
+ supplies most of it from rows comicbox already fetched. Metron always
+ has the start year, but its format, publisher and issue count cost
+ comicbox a request, so they arrive only when titles would otherwise
+ collide. Any of it may be absent, and a prompt cached before comicbox
+ carried it shows neither line.
+
The thumbnail loads straight from the source CDN. An
is exempt
from CORS, and codex's own img-src allows the two upload paths (see
_ONLINE_TAG_COVER_SECURE_CSP), so there is nothing to proxy. A blocked
@@ -44,9 +54,6 @@
({{ candidate.summary.year }})
-
- — {{ candidate.summary.publisher }}
-
{{ sourceLabel(candidate.source) }}
{{ mdiOpenInNew }}
+
+ {{ seriesDetail }}
+
+
+
+ {{ issueDetail }}
+
@@ -67,10 +81,19 @@
{{ scoreDetail }}
- {{ scorePercent }}%
-
- Pick
-
+
+
+ {{ scorePercent }}%
+
+ Pick
+
+
@@ -107,6 +130,31 @@ export default {
scorePercent() {
return Math.round(this.candidate.score * PERCENT);
},
+ seriesDetail() {
+ const {
+ seriesPublisher,
+ publisher,
+ seriesType,
+ seriesStartYear,
+ seriesIssueCount,
+ } = this.candidate.summary;
+ return [
+ seriesPublisher || publisher,
+ seriesType,
+ seriesStartYear ? `Series began ${seriesStartYear}` : "",
+ seriesIssueCount
+ ? `${seriesIssueCount} ${seriesIssueCount === 1 ? "issue" : "issues"}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ },
+ issueDetail() {
+ const { issueTitle, issueSummary } = this.candidate.summary;
+ return [issueTitle ? `“${issueTitle}”` : "", issueSummary]
+ .filter(Boolean)
+ .join(" — ");
+ },
scoreDetail() {
// Prompts cached before these fields existed carry neither, and
// the headline percentage alone is still meaningful.
@@ -138,6 +186,9 @@ export default {
diff --git a/frontend/tests/unit/candidate-row.test.js b/frontend/tests/unit/candidate-row.test.js
index a3313f9c4..79c10f467 100644
--- a/frontend/tests/unit/candidate-row.test.js
+++ b/frontend/tests/unit/candidate-row.test.js
@@ -214,6 +214,23 @@ describe("CandidateRow details", () => {
expect(mountRow().find(".candidateVolume").exists()).toBe(false);
});
+ /*
+ * On phones the score and Pick wrap below the text as one unit, so the
+ * text gets the row's width. happy-dom applies no media queries; this
+ * pins the grouping the phone styles rely on.
+ */
+ test("groups the score and Pick apart from the text", () => {
+ const wrapper = mountRow();
+ const actions = wrapper.find(".candidateRow > .candidateActions");
+
+ expect(actions.exists()).toBe(true);
+ expect(actions.find(".v-chip").text()).toBe("92%");
+ expect(actions.find("button").text()).toBe("Pick");
+ expect(wrapper.find(".candidateInfo .candidateActions").exists()).toBe(
+ false,
+ );
+ });
+
test("emits pick", async () => {
const wrapper = mountRow();
@@ -222,3 +239,74 @@ describe("CandidateRow details", () => {
expect(wrapper.emitted("pick")).toHaveLength(1);
});
});
+
+/*
+ * Near-identical covers with identical titles are common: a reprint
+ * volume, a foreign or a collected edition. Two subdued lines under the
+ * title carry what tells them apart.
+ */
+describe("CandidateRow series and issue detail", () => {
+ const DETAIL = {
+ seriesPublisher: "DC Comics",
+ seriesType: "Ongoing Series",
+ seriesStartYear: 2016,
+ seriesIssueCount: 85,
+ issueTitle: "I Am Gotham, Part One",
+ issueSummary: "A new hero arrives.",
+ };
+
+ test("joins what the source knows about the series", () => {
+ const line = mountRow({ summary: DETAIL }).find(".candidateSeriesDetail");
+
+ expect(line.text()).toBe(
+ "DC Comics · Ongoing Series · Series began 2016 · 85 issues",
+ );
+ });
+
+ test("a one-issue series says issue, not issues", () => {
+ const line = mountRow({ summary: { seriesIssueCount: 1 } }).find(
+ ".candidateSeriesDetail",
+ );
+
+ expect(line.text()).toBe("Dark Horse · 1 issue");
+ });
+
+ test("the series publisher wins over the scored one", () => {
+ const line = mountRow({ summary: { seriesPublisher: "Panini" } }).find(
+ ".candidateSeriesDetail",
+ );
+
+ expect(line.text()).toBe("Panini");
+ });
+
+ test("the publisher has left the title line", () => {
+ const wrapper = mountRow();
+
+ expect(wrapper.find(".candidatePublisher").exists()).toBe(false);
+ expect(wrapper.find(".candidateSeriesDetail").text()).toBe("Dark Horse");
+ });
+
+ test("quotes the issue title before its deck, the whole as a tooltip", () => {
+ const line = mountRow({ summary: DETAIL }).find(".candidateIssueDetail");
+ const text = "“I Am Gotham, Part One” — A new hero arrives.";
+
+ expect(line.text()).toBe(text);
+ expect(line.attributes("title")).toBe(text);
+ });
+
+ test.each([
+ [{ issueTitle: "Title" }, "“Title”"],
+ [{ issueSummary: "Deck" }, "Deck"],
+ ])("shows either half of the issue line alone", (summary, expected) => {
+ const line = mountRow({ summary }).find(".candidateIssueDetail");
+
+ expect(line.text()).toBe(expected);
+ });
+
+ test("a prompt cached before comicbox carried these shows neither line", () => {
+ const wrapper = mountRow({ summary: { publisher: null } });
+
+ expect(wrapper.find(".candidateSeriesDetail").exists()).toBe(false);
+ expect(wrapper.find(".candidateIssueDetail").exists()).toBe(false);
+ });
+});
diff --git a/tests/test_onlinetag_serialize_candidate.py b/tests/test_onlinetag_serialize_candidate.py
index 5759964c4..e3c017b84 100644
--- a/tests/test_onlinetag_serialize_candidate.py
+++ b/tests/test_onlinetag_serialize_candidate.py
@@ -11,6 +11,7 @@
the serializer against real comicbox objects.
"""
+from types import MappingProxyType, SimpleNamespace
from typing import Final
from comicbox.formats.base.online.profile import Candidate, CandidateSummary
@@ -25,6 +26,16 @@
_METADATA_SCORE: Final = 1.0
_COVER_SCORE: Final = 0.6
_VOLUME: Final = 2
+_DETAIL: Final = MappingProxyType(
+ {
+ "series_start_year": 2016,
+ "series_publisher": "DC Comics",
+ "series_issue_count": 85,
+ "series_type": "Ongoing Series",
+ "issue_title": "I Am Gotham, Part One",
+ "issue_summary": "A new hero arrives.",
+ }
+)
def _summary(
@@ -169,6 +180,25 @@ class _Bare:
assert data["cover_score"] is None
assert data["cover_hash_attempted"] is False
assert data["volume_id"] is None
+ for key in _DETAIL:
+ assert data["summary"][key] is None, key
+
+ def test_what_tells_identical_titles_apart_is_carried(self) -> None:
+ """
+ Near-identical covers and titles differ in these.
+
+ The summary is a stand-in because the pinned comicbox predates
+ the fields; the serializer reads them by name either way.
+ """
+ summary = SimpleNamespace(series="Batman", issue="1", year=2016, **_DETAIL)
+ candidate = SimpleNamespace(
+ source="comicvine", issue_id=1, score=0.5, summary=summary
+ )
+
+ data = serialize_candidate(candidate)
+
+ for key, value in _DETAIL.items():
+ assert data["summary"][key] == value, key
def test_display_fields_do_not_bump_the_prompt_version(self) -> None:
"""