mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
merge: slice 17 — ConceptCard data source (release-0.3.2)
This commit is contained in:
commit
2e809917e9
6 files changed
+339
-12
No files matched your search
@@ -93,6 +93,11 @@ export interface SliceListEntry {
|
||||
workflowSpec: WorkflowSpecRef | null;
|
||||
status: SliceStatus;
|
||||
rawStatus: string | null;
|
||||
/** OPR.0.3.2.17 — frontmatter `description` / `summary` from the
|
||||
* slice's primary doc (README/IMPLEMENTATION-PRD/PROGRESS). Used by
|
||||
* the storytelling adapter as the ConceptCard.oneLiner for
|
||||
* `rawStatus === "candidate"` slices. null when absent. */
|
||||
description: string | null;
|
||||
qitemCount: number;
|
||||
hasProofPacket: boolean;
|
||||
lastActivityAt: string | null;
|
||||
@@ -299,6 +304,12 @@ export class SliceIndexer {
|
||||
workflowSpec: parseWorkflowSpecRef(frontmatter["workflow_spec"]),
|
||||
status,
|
||||
rawStatus: (frontmatter["status"] as string | undefined) ?? null,
|
||||
// OPR.0.3.2.17 — surface frontmatter `description` (or `summary`
|
||||
// fallback) so the storytelling adapter can use it as the
|
||||
// ConceptCard.oneLiner without a separate detail fetch. The
|
||||
// detail endpoint stays the source of truth for full body
|
||||
// content; this is the short-form summary mirror.
|
||||
description: extractDescription(frontmatter),
|
||||
qitemCount: qitemIds.length,
|
||||
hasProofPacket: proofPacket !== null,
|
||||
lastActivityAt,
|
||||
@@ -670,6 +681,23 @@ export function parseFrontmatter(content: string): Record<string, unknown> {
|
||||
* missing, non-string, or doesn't match the `<name>@<version>` shape.
|
||||
* Exported so the missions route can reuse the same parser on the
|
||||
* mission README's frontmatter. */
|
||||
/**
|
||||
* OPR.0.3.2.17 — extract a short description from slice frontmatter.
|
||||
* Tries `description` first, then `summary`. Returns null when both
|
||||
* are absent or non-string. Whitespace trimmed; empty strings become
|
||||
* null so the adapter's graceful-empty fallback fires.
|
||||
*/
|
||||
export function extractDescription(frontmatter: Record<string, unknown>): string | null {
|
||||
for (const key of ["description", "summary"]) {
|
||||
const v = frontmatter[key];
|
||||
if (typeof v === "string") {
|
||||
const trimmed = v.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWorkflowSpecRef(raw: unknown): WorkflowSpecRef | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
|
||||
@@ -522,4 +522,49 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
expect(indexer.list()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.2.17 — SliceListEntry surfaces frontmatter `description`
|
||||
// so the storytelling adapter can use it as ConceptCard.oneLiner
|
||||
// for `rawStatus === "candidate"` slices. Mapping: description first,
|
||||
// summary fallback, null when both absent.
|
||||
describe("OPR.0.3.2.17 — frontmatter description exposed on SliceListEntry", () => {
|
||||
it("description: <text> populates SliceListEntry.description", () => {
|
||||
writeSlice(slicesRoot, "concept-restore", {
|
||||
"README.md": "---\nstatus: candidate\ndescription: First-class restore packet.\n---\n# Restore",
|
||||
});
|
||||
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
const entries = indexer.list();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]!.description).toBe("First-class restore packet.");
|
||||
expect(entries[0]!.rawStatus).toBe("candidate");
|
||||
});
|
||||
|
||||
it("summary: <text> is the fallback when description is absent", () => {
|
||||
writeSlice(slicesRoot, "concept-with-summary", {
|
||||
"README.md": "---\nstatus: candidate\nsummary: Falls back to summary.\n---\n# Slice",
|
||||
});
|
||||
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
const entries = indexer.list();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]!.description).toBe("Falls back to summary.");
|
||||
});
|
||||
|
||||
it("description=null when both description and summary are absent", () => {
|
||||
writeSlice(slicesRoot, "no-desc", {
|
||||
"README.md": "---\nstatus: candidate\n---\n# No desc",
|
||||
});
|
||||
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
const entries = indexer.list();
|
||||
expect(entries[0]!.description).toBeNull();
|
||||
});
|
||||
|
||||
it("empty/whitespace-only description value is normalized to null (graceful-empty input)", () => {
|
||||
writeSlice(slicesRoot, "empty-desc", {
|
||||
"README.md": "---\nstatus: candidate\ndescription: ' '\n---\n# Slice",
|
||||
});
|
||||
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
const entries = indexer.list();
|
||||
expect(entries[0]!.description).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -501,18 +501,51 @@ export interface AdapterSliceRow {
|
||||
displayName?: string;
|
||||
status?: string | null;
|
||||
lastActivityAt?: string | null;
|
||||
/**
|
||||
* OPR.0.3.2.17 — raw frontmatter `status` value (preserved alongside
|
||||
* the bucketed `status`) so the adapter can route candidate slices
|
||||
* to ConceptCard without changing the daemon's bucketing logic.
|
||||
* Filled from SliceListEntry.rawStatus when callers thread sliceRows
|
||||
* through.
|
||||
*/
|
||||
rawStatus?: string | null;
|
||||
/**
|
||||
* OPR.0.3.2.17 — frontmatter `description` for the slice. Used as
|
||||
* ConceptCard.oneLiner when the slice routes via `rawStatus ===
|
||||
* "candidate"`. Absent values fall back to a stable placeholder so
|
||||
* the band renders gracefully.
|
||||
*/
|
||||
description?: string | null;
|
||||
}
|
||||
// Wired card kinds (post 0.3.1 storytelling-adapter wire-up):
|
||||
// Wired card kinds (post OPR.0.3.2.17 — ConceptCard data source wired):
|
||||
// - progress ← missions (mapped from useMissionDiscovery rows)
|
||||
// - shipped ← slices with status shipped/complete/done
|
||||
// - incident ← slices with status blocked/failed/danger/etc.
|
||||
// - approval ← FeedCard items with kind === "approval" (from
|
||||
// useActivityFeed → classifyFeed) — caller threads in
|
||||
// via the 4th arg.
|
||||
// Deferred: concept — ConceptCard component is preserved for a 0.3.2
|
||||
// slice that picks a deliberate data source; until then the adapter
|
||||
// never emits concept items and the production switch in
|
||||
// StorytellingFeed has no concept branch.
|
||||
// - concept ← slices with rawStatus === "candidate" (shaped backlog
|
||||
// items): title ← displayName; oneLiner ← description;
|
||||
// drillInHref ← /project/slice/<name>. Capped at 2
|
||||
// per the curated-band rule (HG-4). Graceful-empty
|
||||
// when no candidate rows exist (HG-2).
|
||||
/**
|
||||
* OPR.0.3.2.17 — predicate for the ConceptCard data source per PRD
|
||||
* Option A. A slice qualifies as a "shaped backlog candidate" when
|
||||
* BOTH conditions hold:
|
||||
* - frontmatter status is "candidate" (case-insensitive)
|
||||
* - the slice lives under the `backlog` mission
|
||||
*
|
||||
* Exported so the predicate is a discrete, testable surface — guard
|
||||
* BLOCKING qitem-20260518093643 flagged that the original filter
|
||||
* (rawStatus alone) over-matched any-mission candidate slices.
|
||||
*/
|
||||
export function isBacklogCandidateSlice(slice: AdapterSliceRow): boolean {
|
||||
const status = (slice.rawStatus ?? "").toLowerCase();
|
||||
if (status !== "candidate") return false;
|
||||
return slice.missionId === "backlog";
|
||||
}
|
||||
|
||||
export function buildStorytellingFeedItems(
|
||||
missions: AdapterMissionRow[],
|
||||
slices: AdapterSliceRow[],
|
||||
@@ -562,7 +595,42 @@ export function buildStorytellingFeedItems(
|
||||
const eligibleSlices = (slices ?? []).filter((slice) => {
|
||||
return !slice.missionId || !hiddenMissionIds.has(slice.missionId);
|
||||
});
|
||||
for (const slice of eligibleSlices.slice(0, 3)) {
|
||||
|
||||
// OPR.0.3.2.17 — ConceptCard routing.
|
||||
// PRD Option A: source is specifically shaped backlog candidates at
|
||||
// `missions/backlog/slices/<slug>/README.md` with `status: candidate`
|
||||
// — NOT any candidate slice across any mission. The predicate
|
||||
// requires BOTH conditions; a `status: candidate` slice under a
|
||||
// different mission (e.g., release-0.3.2) routes through the normal
|
||||
// status-bucket path, not as a concept item. This keeps the
|
||||
// ConceptCard band semantically aligned with the operator's
|
||||
// "shaped backlog" mental model.
|
||||
//
|
||||
// Partition candidates out of the normal status-bucket routing so a
|
||||
// single slice is routed to exactly ONE kind (HG-5: no double-count).
|
||||
// The curated band caps at 2 (HG-4). Graceful-empty (HG-2): when no
|
||||
// candidate rows are present this branch contributes zero items.
|
||||
const candidateSlices = eligibleSlices.filter(isBacklogCandidateSlice);
|
||||
const nonCandidateSlices = eligibleSlices.filter(
|
||||
(s) => !isBacklogCandidateSlice(s),
|
||||
);
|
||||
for (const slice of candidateSlices.slice(0, 2)) {
|
||||
const title = slice.displayName || slice.name;
|
||||
const oneLinerRaw = (slice.description ?? "").trim();
|
||||
const oneLiner = oneLinerRaw.length > 0
|
||||
? oneLinerRaw
|
||||
: "Shaped candidate — open to explore the proposed change.";
|
||||
items.push({
|
||||
kind: "concept",
|
||||
source: {
|
||||
sliceId: slice.name,
|
||||
title,
|
||||
oneLiner,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const slice of nonCandidateSlices.slice(0, 3)) {
|
||||
const oneLiner = slice.lastActivityAt
|
||||
? `Last activity ${slice.lastActivityAt}`
|
||||
: `Slice in ${slice.status ?? "unknown"} state`;
|
||||
@@ -634,13 +702,15 @@ export function StorytellingFeed({
|
||||
return (
|
||||
<div data-testid="storytelling-feed" className="flex flex-col gap-4">
|
||||
{items.map((item, i) => {
|
||||
// ConceptCard is preserved as a component but is not reachable
|
||||
// here — the adapter never emits `kind: "concept"`. A 0.3.2
|
||||
// slice will pick a data source and re-add the branch.
|
||||
// OPR.0.3.2.17 — ConceptCard branch re-added. Adapter emits
|
||||
// `kind: "concept"` for shaped backlog candidates
|
||||
// (rawStatus === "candidate"); the switch routes them to
|
||||
// ConceptCard. Other 4 kinds unchanged (HG-5).
|
||||
if (item.kind === "shipped") return <ShippedCard key={i} source={item.source} />;
|
||||
if (item.kind === "incident") return <IncidentCard key={i} source={item.source} />;
|
||||
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} onMarkComplete={onMarkMissionComplete} />;
|
||||
if (item.kind === "approval") return <ApprovalCard key={i} source={item.source} />;
|
||||
if (item.kind === "concept") return <ConceptCard key={i} source={item.source} />;
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface SliceListEntry {
|
||||
railItem: string | null;
|
||||
status: SliceStatus;
|
||||
rawStatus: string | null;
|
||||
/** OPR.0.3.2.17 — short description from slice frontmatter
|
||||
* (description/summary fallback). Used by the storytelling adapter
|
||||
* as ConceptCard.oneLiner for `rawStatus === "candidate"` slices.
|
||||
* null when absent. */
|
||||
description?: string | null;
|
||||
qitemCount: number;
|
||||
hasProofPacket: boolean;
|
||||
lastActivityAt: string | null;
|
||||
|
||||
@@ -178,4 +178,33 @@ describe("StorytellingFeed — composition", () => {
|
||||
const { getByTestId } = render(<StorytellingFeed items={[]} />);
|
||||
expect(getByTestId("storytelling-feed-empty").textContent).toContain("No items");
|
||||
});
|
||||
|
||||
// OPR.0.3.2.17 — StorytellingFeed switch carries the concept branch
|
||||
// (previously stripped at 0.3.1). Renders ConceptCard when adapter
|
||||
// emits kind:"concept". HG-6 fail-first: if the switch loses the
|
||||
// concept branch (silent regression), this test FAILS — no concept
|
||||
// testid is found in the DOM.
|
||||
it("OPR.0.3.2.17: renders a ConceptCard when items include kind:'concept' (HG-6 fail-first)", () => {
|
||||
const items: FeedCardItem[] = [
|
||||
{
|
||||
kind: "concept",
|
||||
source: { sliceId: "concept-restore-packet", title: "Restore packet primitive", oneLiner: "Shaped candidate." },
|
||||
},
|
||||
];
|
||||
const { getByTestId } = render(<StorytellingFeed items={items} />);
|
||||
expect(getByTestId("feed-card-concept-concept-restore-packet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("OPR.0.3.2.17: ConceptCard renders alongside the existing 4 card kinds (HG-5 no regression)", () => {
|
||||
const items: FeedCardItem[] = [
|
||||
{ kind: "shipped", source: { sliceId: "a", title: "A", oneLiner: "x" } },
|
||||
{ kind: "incident", source: { sliceId: "b", title: "B", oneLiner: "x", status: "info" } },
|
||||
{ kind: "progress", source: { missionId: "m", title: "M", oneLiner: "x", percent: 50 } },
|
||||
{ kind: "approval", source: { qitemId: "q1", title: "Approve", oneLiner: "x", drillInHref: "/for-you" } },
|
||||
{ kind: "concept", source: { sliceId: "c1", title: "Concept", oneLiner: "Shaped." } },
|
||||
];
|
||||
const { container } = render(<StorytellingFeed items={items} />);
|
||||
expect(container.querySelectorAll("[data-testid^='feed-card-']").length).toBeGreaterThanOrEqual(5);
|
||||
expect(container.querySelector("[data-testid='feed-card-concept-c1']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -189,14 +189,164 @@ describe("buildStorytellingFeedItems — production adapter", () => {
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits concept-kind items — ConceptCard is deferred to a 0.3.2 slice", () => {
|
||||
// OPR.0.3.2.17 — ConceptCard data source (replacing the 0.3.1 deferred
|
||||
// pin; HG-6 fail-first discriminator).
|
||||
//
|
||||
// Source: shaped backlog candidates — SliceListEntry rows where
|
||||
// rawStatus === "candidate" (case-insensitive).
|
||||
// Mapping: sliceId ← name; title ← displayName (or name); oneLiner ←
|
||||
// frontmatter description (passed through as the adapter row's
|
||||
// `description` field).
|
||||
// Graceful-empty (HG-2): no candidate rows → no concept items, no
|
||||
// error; other kinds unaffected.
|
||||
|
||||
it("HG-1: rawStatus='candidate' slice emits a ConceptCard via the adapter (fail-first; passes only when concept branch is wired)", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[
|
||||
{
|
||||
name: "concept-restore-packet",
|
||||
missionId: "backlog",
|
||||
displayName: "Restore packet primitive",
|
||||
status: "draft",
|
||||
rawStatus: "candidate",
|
||||
description: "First-class restore packet so seats survive compaction.",
|
||||
},
|
||||
],
|
||||
);
|
||||
const concepts = items.filter((i) => i.kind === "concept");
|
||||
expect(concepts).toHaveLength(1);
|
||||
if (concepts[0]!.kind === "concept") {
|
||||
expect(concepts[0]!.source.sliceId).toBe("concept-restore-packet");
|
||||
expect(concepts[0]!.source.title).toBe("Restore packet primitive");
|
||||
expect(concepts[0]!.source.oneLiner).toBe("First-class restore packet so seats survive compaction.");
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-1: rawStatus='Candidate' (mixed case) still emits ConceptCard", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[{ name: "c1", missionId: "backlog", displayName: "c1", status: "draft", rawStatus: "Candidate", description: "d" }],
|
||||
);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("HG-2 graceful-empty: zero candidate rows → no concept items, no error, other kinds still render", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[{ name: "m1", path: "missions/m1" }],
|
||||
[{ name: "s1", status: "shipped" }, { name: "s2", status: "blocked" }],
|
||||
[
|
||||
{ name: "s-shipped", status: "shipped" },
|
||||
{ name: "s-blocked", status: "blocked" },
|
||||
// no rawStatus='candidate' row anywhere
|
||||
],
|
||||
undefined,
|
||||
[makeApprovalFeedCard({ qitemId: "q1" })],
|
||||
);
|
||||
expect(items.some((i) => i.kind === "concept")).toBe(false);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(0);
|
||||
expect(items.filter((i) => i.kind === "progress")).toHaveLength(1);
|
||||
expect(items.filter((i) => i.kind === "shipped")).toHaveLength(1);
|
||||
expect(items.filter((i) => i.kind === "incident")).toHaveLength(1);
|
||||
expect(items.filter((i) => i.kind === "approval")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("HG-4 cap: ConceptCard items capped at 2 per the curated-band rule", () => {
|
||||
const slices = Array.from({ length: 5 }).map((_, i) => ({
|
||||
name: `cand-${i}`,
|
||||
missionId: "backlog",
|
||||
displayName: `Candidate ${i}`,
|
||||
status: "draft" as const,
|
||||
rawStatus: "candidate",
|
||||
description: `desc-${i}`,
|
||||
}));
|
||||
const items = buildStorytellingFeedItems([], slices);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("HG-5 no regression: candidate slices do NOT also emit shipped/incident items (concept routing is exclusive)", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[{ name: "c1", missionId: "backlog", displayName: "c1", status: "draft", rawStatus: "candidate", description: "d" }],
|
||||
);
|
||||
// Concept-routed rows must not double-count into shipped/incident.
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]!.kind).toBe("concept");
|
||||
});
|
||||
|
||||
it("ConceptCardSource oneLiner falls back to a stable placeholder when description is missing (defense; PRD allows graceful)", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[{ name: "c1", missionId: "backlog", displayName: "c1", status: "draft", rawStatus: "candidate" }],
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0]!.kind === "concept") {
|
||||
expect(typeof items[0]!.source.oneLiner).toBe("string");
|
||||
expect(items[0]!.source.oneLiner.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Guard BLOCKER qitem-20260518093643 — PRD Option A scopes the
|
||||
// ConceptCard source narrowly: missions/backlog/slices/<slug> with
|
||||
// `status: candidate`. A `status: candidate` row under any other
|
||||
// mission (or with missionId === null) must NOT emit a concept item;
|
||||
// it routes through the normal status-bucket path instead.
|
||||
it("BLOCKER fix: candidate slice under a NON-backlog mission emits zero ConceptCards (PRD Option A — backlog-only)", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[
|
||||
{ name: "non-backlog-candidate", missionId: "release-0.3.2", displayName: "Non-backlog", status: "draft", rawStatus: "candidate", description: "shaped under release lane" },
|
||||
],
|
||||
);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(0);
|
||||
// Falls through to status-bucket path; status="draft" routes to incident-info.
|
||||
expect(items.filter((i) => i.kind === "incident")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("BLOCKER fix: candidate slice with missionId=null emits zero ConceptCards", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[
|
||||
{ name: "orphan-candidate", missionId: null, displayName: "Orphan", status: "draft", rawStatus: "candidate", description: "shaped without mission" },
|
||||
],
|
||||
);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("BLOCKER fix: backlog candidate STILL emits exactly one ConceptCard (positive case preserved)", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[
|
||||
{ name: "backlog-candidate", missionId: "backlog", displayName: "Backlog", status: "draft", rawStatus: "candidate", description: "shaped backlog item" },
|
||||
],
|
||||
);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("BLOCKER fix: mixed input — 2 backlog candidates + 2 non-backlog candidates → 2 ConceptCards (only backlog), non-backlog as incidents", () => {
|
||||
const items = buildStorytellingFeedItems(
|
||||
[],
|
||||
[
|
||||
{ name: "b1", missionId: "backlog", displayName: "B1", status: "draft", rawStatus: "candidate", description: "d" },
|
||||
{ name: "b2", missionId: "backlog", displayName: "B2", status: "draft", rawStatus: "candidate", description: "d" },
|
||||
{ name: "x1", missionId: "release-0.3.2", displayName: "X1", status: "draft", rawStatus: "candidate", description: "d" },
|
||||
{ name: "x2", missionId: null, displayName: "X2", status: "draft", rawStatus: "candidate", description: "d" },
|
||||
],
|
||||
);
|
||||
expect(items.filter((i) => i.kind === "concept")).toHaveLength(2);
|
||||
// Non-backlog candidates fall through to incident (status: draft → info).
|
||||
expect(items.filter((i) => i.kind === "incident")).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Pure-helper unit test — the predicate is exported separately for
|
||||
// discrete pinning.
|
||||
it("isBacklogCandidateSlice predicate: backlog+candidate=true; everything else=false", async () => {
|
||||
const { isBacklogCandidateSlice } = await import("../src/components/feed/cards/storytelling-cards.js");
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: "candidate" })).toBe(true);
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: "Candidate" })).toBe(true);
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: "release-0.3.2", rawStatus: "candidate" })).toBe(false);
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: null, rawStatus: "candidate" })).toBe(false);
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: "shipped" })).toBe(false);
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: null })).toBe(false);
|
||||
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog" })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to FeedCard.id when payload has no qitemId/qitem_id key", () => {
|
||||
|
||||
Reference in new issue
Block a user