diff --git a/CHANGELOG.md b/CHANGELOG.md index f29707f..20d411f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index d5a3ab3..bad34a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0.3 +0.1.0.4 diff --git a/content b/content index 2cfec56..212f9e5 160000 --- a/content +++ b/content @@ -1 +1 @@ -Subproject commit 2cfec5664210830df520d68070ec5606084a492e +Subproject commit 212f9e5f5d20802c178312a30c41aa7d384601df diff --git a/package.json b/package.json index 216c261..a0f7c35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "baymac.lol", - "version": "0.1.0.3", + "version": "0.1.0.4", "private": true, "author": { "name": "Parichay Barpanda", diff --git a/tests/unit/components/BuyMeCryptoCard.test.tsx b/tests/unit/components/BuyMeCryptoCard.test.tsx new file mode 100644 index 0000000..f963536 --- /dev/null +++ b/tests/unit/components/BuyMeCryptoCard.test.tsx @@ -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(); + expect(container.textContent).toContain('no wallets configured'); + expect(container.textContent).toContain('wallets.json'); + }); + + it('renders the first wallet address by default', async () => { + await render(); + expect(container.textContent).toContain('bc1qtest123'); + expect(container.textContent).toContain('Bitcoin'); + }); + + it('renders coin tab buttons for each wallet', async () => { + await render(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/tests/unit/components/PostBanner.test.tsx b/tests/unit/components/PostBanner.test.tsx new file mode 100644 index 0000000..1d010f4 --- /dev/null +++ b/tests/unit/components/PostBanner.test.tsx @@ -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'); + }); +}); diff --git a/tests/unit/context/PostBannerProvider.test.tsx b/tests/unit/context/PostBannerProvider.test.tsx new file mode 100644 index 0000000..0ca4acd --- /dev/null +++ b/tests/unit/context/PostBannerProvider.test.tsx @@ -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 }) { + const ctx = usePostBanner(); + Object.assign(out, ctx); + return null; +} + +async function renderProvider(out: Record) { + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + await renderProvider(out); + expect(out.secondsLeft).toBeNull(); + }); + + it('provides stable registerTitleEl reference (useCallback)', async () => { + const out: Record = {}; + 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); + }); +}); diff --git a/tests/unit/lib/wallets.test.ts b/tests/unit/lib/wallets.test.ts new file mode 100644 index 0000000..b421f3c --- /dev/null +++ b/tests/unit/lib/wallets.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'bun:test'; +import { getWallets } from '../../../lib/wallets'; + +// getWallets() reads content/wallets.json from the submodule. In CI without +// `git submodule update --init`, the file is absent and getWallets() returns []. +// We guard against that here so the test suite doesn't fail on a missing submodule. +const wallets = getWallets(); +const hasWallets = wallets.length > 0; + +describe('getWallets', () => { + it('returns an array', () => { + expect(Array.isArray(wallets)).toBe(true); + }); + + it('returns [] when wallets.json is missing (submodule not initialised)', () => { + // This test always passes: either wallets exist (submodule is init'd) + // or getWallets() correctly returned [] (file-missing catch branch). + expect(Array.isArray(wallets)).toBe(true); + }); + + it("each wallet has uppercase coin, non-empty label/network/address (when submodule init'd)", () => { + if (!hasWallets) return; // skip when running without submodule + for (const w of wallets) { + expect(w.coin).toBe(w.coin.toUpperCase()); + expect(typeof w.label).toBe('string'); + expect(w.label.length).toBeGreaterThan(0); + expect(typeof w.network).toBe('string'); + expect(w.address.length).toBeGreaterThan(0); + } + }); + + it("coin codes are alphanumeric and all caps (when submodule init'd)", () => { + if (!hasWallets) return; + for (const w of wallets) { + expect(/^[A-Z0-9]+$/.test(w.coin)).toBe(true); + } + }); +});