feat(viewer): bundle md + slug + conditional owner pill [v0.2.0]
Closes 3 of 6 UX-round1 fixes in one TDD cycle (49→56 tests). drawer-bundle-md: TaskRecord.md_content fetched in build-time; drawer reads recordsBySlug instead of fetch — 0 runtime hits to git.kzntsv.site/raw (private repos 401'd browser fetches without token). Fallback dl-block when md=null. ux-show-slug: <code class="card-slug"> rendered under card-title in both render.ts and board.js renderCard (re-render path). ux-owner-conditional: distinctOwner(records) pre-scan. Single owner → pill omitted on cards, header gets <span class="owner-summary"> Owner: X</span>. Multi-owner → pill restored, no header summary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,11 @@ _Updated: 2026-05-22_
|
||||
describe('readBoard', () => {
|
||||
test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => {
|
||||
const client = fakeClient({
|
||||
getFile: vi.fn().mockResolvedValue(STATUS_MD_ONE),
|
||||
getFile: vi.fn(async (_o: string, _r: string, path: string) => {
|
||||
if (path === '.tasks/STATUS.md') return STATUS_MD_ONE;
|
||||
if (path === '.tasks/task-a.md') return '# task-a\n\ndeep context body.';
|
||||
return null;
|
||||
}),
|
||||
getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'),
|
||||
rawUrl: vi.fn().mockReturnValue(
|
||||
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
|
||||
@@ -50,9 +54,40 @@ describe('readBoard', () => {
|
||||
last_commit_iso: '2026-05-22T09:00:00Z',
|
||||
raw_url:
|
||||
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
|
||||
md_content: '# task-a\n\ndeep context body.',
|
||||
});
|
||||
});
|
||||
|
||||
test('per-task .md missing (404) → md_content is null, record still emitted', async () => {
|
||||
const client = fakeClient({
|
||||
getFile: vi.fn(async (_o: string, _r: string, path: string) => {
|
||||
if (path === '.tasks/STATUS.md') return STATUS_MD_ONE;
|
||||
return null;
|
||||
}),
|
||||
getLatestCommitIso: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
const records = await readBoard(client, [{ owner: 'o', repo: 'r' }]);
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]!.md_content).toBeNull();
|
||||
});
|
||||
|
||||
test('fetches per-task .md alongside STATUS.md (.tasks/<slug>.md path)', async () => {
|
||||
const getFile = vi.fn(async (_o: string, _r: string, path: string) => {
|
||||
if (path === '.tasks/STATUS.md') return STATUS_MD_ONE;
|
||||
return 'body';
|
||||
});
|
||||
const client = fakeClient({
|
||||
getFile,
|
||||
getLatestCommitIso: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
await readBoard(client, [{ owner: 'o', repo: 'r' }]);
|
||||
|
||||
expect(getFile).toHaveBeenCalledWith('o', 'r', '.tasks/task-a.md');
|
||||
});
|
||||
|
||||
test('skips repo silently when STATUS.md is missing (returns no records for that repo)', async () => {
|
||||
const client = fakeClient({
|
||||
getFile: vi.fn().mockResolvedValue(null), // 404
|
||||
|
||||
@@ -16,6 +16,7 @@ function rec(overrides: Partial<TaskRecord>): TaskRecord {
|
||||
branch: 'master',
|
||||
last_commit_iso: null,
|
||||
raw_url: 'https://example/raw',
|
||||
md_content: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -154,22 +155,83 @@ describe('renderBoard', () => {
|
||||
expect(html).toContain('"embed-test"');
|
||||
});
|
||||
|
||||
test('renders owner as a pill when project_owner is non-empty', () => {
|
||||
test('bundles per-task md_content inside records JSON (drawer reads it without fetch)', () => {
|
||||
const records = [rec({ slug: 'with-md', md_content: '# heading\n\nbody text' })];
|
||||
|
||||
const html = renderBoard(records, { generatedAt: now });
|
||||
|
||||
// md is inside the records script tag, JSON-escaped (newlines as \n)
|
||||
expect(html).toContain('"md_content":"# heading\\n\\nbody text"');
|
||||
});
|
||||
|
||||
test('renders owner as a pill when records span 2+ distinct owners', () => {
|
||||
const html = renderBoard(
|
||||
[rec({ slug: 'x', project_owner: 'OpeItcLoc03' })],
|
||||
[
|
||||
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
|
||||
rec({ slug: 'y', project_owner: 'bob', project: 'b' }),
|
||||
],
|
||||
{ generatedAt: now },
|
||||
);
|
||||
|
||||
expect(html).toMatch(/<span class="badge owner">OpeItcLoc03<\/span>/);
|
||||
expect(html).toMatch(/<span class="badge owner">alice<\/span>/);
|
||||
expect(html).toMatch(/<span class="badge owner">bob<\/span>/);
|
||||
});
|
||||
|
||||
test('omits owner pill on every card when all records share a single owner', () => {
|
||||
const html = renderBoard(
|
||||
[
|
||||
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
|
||||
rec({ slug: 'y', project_owner: 'alice', project: 'b' }),
|
||||
],
|
||||
{ generatedAt: now },
|
||||
);
|
||||
|
||||
expect(html).not.toMatch(/<span class="badge owner">/);
|
||||
});
|
||||
|
||||
test('emits a single-owner header summary when whitelist is mono-owner', () => {
|
||||
const html = renderBoard(
|
||||
[
|
||||
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
|
||||
rec({ slug: 'y', project_owner: 'alice', project: 'b' }),
|
||||
],
|
||||
{ generatedAt: now },
|
||||
);
|
||||
|
||||
expect(html).toMatch(/<span class="owner-summary"[^>]*>Owner: alice<\/span>/);
|
||||
});
|
||||
|
||||
test('omits the owner-summary header when whitelist is multi-owner', () => {
|
||||
const html = renderBoard(
|
||||
[
|
||||
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
|
||||
rec({ slug: 'y', project_owner: 'bob', project: 'b' }),
|
||||
],
|
||||
{ generatedAt: now },
|
||||
);
|
||||
|
||||
expect(html).not.toContain('owner-summary');
|
||||
});
|
||||
|
||||
test('omits owner pill when project_owner is empty string', () => {
|
||||
const html = renderBoard(
|
||||
[rec({ slug: 'x', project_owner: '' })],
|
||||
[
|
||||
rec({ slug: 'x', project_owner: '' }),
|
||||
rec({ slug: 'y', project_owner: 'bob' }),
|
||||
],
|
||||
{ generatedAt: now },
|
||||
);
|
||||
|
||||
expect(html).not.toContain('badge owner');
|
||||
expect(html).not.toMatch(/<span class="badge owner"><\/span>/);
|
||||
});
|
||||
|
||||
test('renders slug as a visible <code class="card-slug"> on every card', () => {
|
||||
const html = renderBoard(
|
||||
[rec({ slug: 'board-viewer-pointers', title: 'pointers' })],
|
||||
{ generatedAt: now },
|
||||
);
|
||||
|
||||
expect(html).toMatch(/<code class="card-slug">board-viewer-pointers<\/code>/);
|
||||
});
|
||||
|
||||
test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {
|
||||
|
||||
Reference in New Issue
Block a user