Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project will be documented in this file.

## [0.1.0.4] - 2026-06-14

### Added
- Blog post update: `at`-command one-time scheduling section added to the Conductor session auto-resume post — shows how to use macOS `at` for a single-shot script run without a recurring launchd job
- Unit test coverage for `PostBannerProvider` (context propagation, `setPost`, `registerTitleEl`), `PostBanner` (all `secondsLeft` display branches, `aria-hidden` state), `BuyMeCryptoCard` (empty-wallets state, coin tab switching, copy error snackbar), and `lib/wallets` (structure + CI guard for uninitialised submodule)

## [0.1.0.3] - 2026-06-13

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0.3
0.1.0.4
2 changes: 1 addition & 1 deletion content
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "baymac.lol",
"version": "0.1.0.3",
"version": "0.1.0.4",
"private": true,
"author": {
"name": "Parichay Barpanda",
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/components/BuyMeCryptoCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';

mock.module('use-clipboard-copy', () => ({
useClipboard: () => ({
copy: (_v: string) => {},
copied: false,
}),
}));

import BuyMeCryptoCard from '../../../components/BuyMeCrypto/BuyMeCryptoCard';
import type { Wallet } from '../../../lib/wallets';

const WALLETS: Wallet[] = [
{ coin: 'BTC', label: 'Bitcoin', network: 'mainnet', address: 'bc1qtest123' },
{ coin: 'SUI', label: 'Sui', network: 'mainnet', address: '0xsuiaddress' },
];

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.innerHTML = '';
});

async function render(ui: React.ReactNode) {
await act(async () => {
root = createRoot(container);
root.render(ui);
});
}

describe('BuyMeCryptoCard', () => {
it('shows empty-state message when wallets array is empty', async () => {
await render(<BuyMeCryptoCard wallets={[]} />);
expect(container.textContent).toContain('no wallets configured');
expect(container.textContent).toContain('wallets.json');
});

it('renders the first wallet address by default', async () => {
await render(<BuyMeCryptoCard wallets={WALLETS} />);
expect(container.textContent).toContain('bc1qtest123');
expect(container.textContent).toContain('Bitcoin');
});

it('renders coin tab buttons for each wallet', async () => {
await render(<BuyMeCryptoCard wallets={WALLETS} />);
const tabs = container.querySelectorAll('[role="tab"]');
expect(tabs.length).toBe(2);
expect(tabs[0].textContent).toBe('BTC');
expect(tabs[1].textContent).toBe('SUI');
});

it('first tab is selected by default', async () => {
await render(<BuyMeCryptoCard wallets={WALLETS} />);
const tabs = container.querySelectorAll('[role="tab"]');
expect((tabs[0] as HTMLElement).getAttribute('aria-selected')).toBe('true');
expect((tabs[1] as HTMLElement).getAttribute('aria-selected')).toBe(
'false'
);
});

it('switching coin tab shows the selected wallet address', async () => {
await render(<BuyMeCryptoCard wallets={WALLETS} />);
const suiTab = container.querySelectorAll(
'[role="tab"]'
)[1] as HTMLButtonElement;
await act(async () => {
suiTab.click();
});
expect(container.textContent).toContain('0xsuiaddress');
expect(container.textContent).toContain('Sui');
});

it('copy button renders with accessible label', async () => {
await render(<BuyMeCryptoCard wallets={WALLETS} />);
const btn = container.querySelector('button[aria-label="Copy address"]');
expect(btn).not.toBeNull();
});

it('shows error snackbar when clipboard is unavailable', async () => {
const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});

await render(<BuyMeCryptoCard wallets={WALLETS} />);
const btn = container.querySelector(
'button[aria-label="Copy address"]'
) as HTMLButtonElement;
await act(async () => {
btn.click();
});

expect(container.textContent).toContain(
'copy failed — select address manually'
);

if (original) Object.defineProperty(navigator, 'clipboard', original);
});
});
112 changes: 112 additions & 0 deletions tests/unit/components/PostBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';

// Stub out the PostBannerProvider context with controllable values.
let ctxValues = {
post: null as { title: string; mins?: number } | null,
progress: 0,
secondsLeft: null as number | null,
setPost: () => {},
registerTitleEl: () => {},
};

mock.module('../../../context/PostBannerProvider', () => ({
usePostBanner: () => ctxValues,
default: ({ children }: { children: React.ReactNode }) => children,
BANNER_RANGE: 52,
}));

import PostBanner from '../../../components/Nav/PostBanner';

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
// Reset to defaults.
ctxValues = {
post: null,
progress: 0,
secondsLeft: null,
setPost: () => {},
registerTitleEl: () => {},
};
});

afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.innerHTML = '';
});

async function render() {
await act(async () => {
root = createRoot(container);
root.render(createElement(PostBanner));
});
}

describe('PostBanner', () => {
it('renders nothing when post is null', async () => {
ctxValues.post = null;
await render();
expect(container.innerHTML).toBe('');
});

it('renders banner when post is set', async () => {
ctxValues.post = { title: 'My Post' };
ctxValues.progress = 0.5;
await render();
expect(container.textContent).toContain('My Post');
});

it('is aria-hidden when progress <= 0.001', async () => {
ctxValues.post = { title: 'Test' };
ctxValues.progress = 0;
await render();
const banner = container.firstElementChild as HTMLElement;
expect(banner?.getAttribute('aria-hidden')).toBe('true');
});

it('is not aria-hidden when progress > 0.001', async () => {
ctxValues.post = { title: 'Test' };
ctxValues.progress = 0.5;
await render();
const banner = container.firstElementChild as HTMLElement;
expect(banner?.getAttribute('aria-hidden')).toBe('false');
});

it('shows "N min left" when secondsLeft >= 60', async () => {
ctxValues.post = { title: 'Test', mins: 5 };
ctxValues.secondsLeft = 180;
await render();
expect(container.textContent).toContain('3 min left');
});

it('shows "N sec left" when 0 < secondsLeft < 60', async () => {
ctxValues.post = { title: 'Test', mins: 5 };
ctxValues.secondsLeft = 42;
await render();
expect(container.textContent).toContain('42 sec left');
});

it('shows done message when secondsLeft is 0', async () => {
ctxValues.post = { title: 'Test', mins: 5 };
ctxValues.secondsLeft = 0;
await render();
expect(container.textContent).toContain('purr purr ~');
});

it('shows no time label when secondsLeft is null', async () => {
ctxValues.post = { title: 'No time' };
ctxValues.secondsLeft = null;
await render();
expect(container.textContent).not.toContain('min left');
expect(container.textContent).not.toContain('sec left');
expect(container.textContent).not.toContain('purr');
});
});
112 changes: 112 additions & 0 deletions tests/unit/context/PostBannerProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import PostBannerProvider, {
usePostBanner,
} from '../../../context/PostBannerProvider';

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.innerHTML = '';
});

// Renders a consumer child that captures context values into `out`.
function ContextCapture({ out }: { out: Record<string, unknown> }) {
const ctx = usePostBanner();
Object.assign(out, ctx);
return null;
}

async function renderProvider(out: Record<string, unknown>) {
await act(async () => {
root = createRoot(container);
root.render(
createElement(
PostBannerProvider,
null,
createElement(ContextCapture, { out })
)
);
});
}

describe('PostBannerProvider', () => {
it('provides default context values on first render', async () => {
const out: Record<string, unknown> = {};
await renderProvider(out);
expect(out.post).toBeNull();
expect(out.progress).toBe(0);
expect(out.secondsLeft).toBeNull();
expect(typeof out.setPost).toBe('function');
expect(typeof out.registerTitleEl).toBe('function');
});

it('setPost propagates new post to consumers', async () => {
const out: Record<string, unknown> = {};
await renderProvider(out);

await act(async () => {
(out.setPost as (p: { title: string; mins?: number } | null) => void)({
title: 'Hello World',
mins: 3,
});
});

expect((out.post as { title: string } | null)?.title).toBe('Hello World');
});

it('setPost with null clears the post', async () => {
const out: Record<string, unknown> = {};
await renderProvider(out);

await act(async () => {
(out.setPost as (p: { title: string } | null) => void)({ title: 'Test' });
});
await act(async () => {
(out.setPost as (p: null) => void)(null);
});

expect(out.post).toBeNull();
});

it('registerTitleEl with null keeps progress at 0', async () => {
const out: Record<string, unknown> = {};
await renderProvider(out);

await act(async () => {
(out.registerTitleEl as (el: HTMLElement | null) => void)(null);
});

expect(out.progress).toBe(0);
});

it('secondsLeft is null when no post is set', async () => {
const out: Record<string, unknown> = {};
await renderProvider(out);
expect(out.secondsLeft).toBeNull();
});

it('provides stable registerTitleEl reference (useCallback)', async () => {
const out: Record<string, unknown> = {};
await renderProvider(out);
const first = out.registerTitleEl;

// Trigger a re-render via setPost.
await act(async () => {
(out.setPost as (p: { title: string }) => void)({ title: 're-render' });
});

expect(out.registerTitleEl).toBe(first);
});
});
Loading
Loading