mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge account-avatar-display refactorer work
By architect.
This commit is contained in:
@@ -53,6 +53,7 @@ const PollOptionPage = require('../../src/services/poll-option-page')
|
||||
const MemoPollVote = require('../../src/services/memo-poll-vote')
|
||||
const PollVotePage = require('../../src/services/poll-vote-page')
|
||||
const { renderPostText } = require('./render-post')
|
||||
const { renderAccountAvatar } = require('./render-account-avatar')
|
||||
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
@@ -926,6 +927,41 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'account page displays avatar image',
|
||||
pattern: /^the account page displays the avatar image with the URL "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
const expected = example[param]
|
||||
if (!world.accountPage.hasAvatarImage()) {
|
||||
throw new Error('Account page does not have an avatar URL to display.')
|
||||
}
|
||||
const actual = world.accountPage.getAvatarImageUrl()
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected avatar URL "${expected}", got "${actual}".`)
|
||||
}
|
||||
const html = renderAccountAvatar(actual)
|
||||
if (!html.includes(`src="${expected}"`)) {
|
||||
throw new Error(`Account page does not render an avatar image with src="${expected}".`)
|
||||
}
|
||||
if (!html.includes('<img')) {
|
||||
throw new Error('Account page does not render an avatar image element.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'account page does not display avatar image',
|
||||
pattern: /^the account page does not display an avatar image$/,
|
||||
run (m, example, world) {
|
||||
if (world.accountPage.hasAvatarImage()) {
|
||||
throw new Error(`Account page unexpectedly has an avatar URL: ${world.accountPage.getAvatarImageUrl()}.`)
|
||||
}
|
||||
const html = renderAccountAvatar(world.accountPage.getAvatarImageUrl())
|
||||
if (html.includes('<img')) {
|
||||
throw new Error('Account page unexpectedly renders an avatar image element.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Set Name button',
|
||||
pattern: /^I click the Set Name button$/,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Acceptance rendering adapter for the account page avatar image.
|
||||
|
||||
Renders the same AvatarImage component the browser uses to a static HTML
|
||||
string, so acceptance assertions can inspect the resulting image markup
|
||||
without running a browser.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const AvatarImage = require('../../src/components/account/avatar-image')
|
||||
|
||||
function renderAccountAvatar (url) {
|
||||
const element = React.createElement(AvatarImage, { url })
|
||||
return ReactDOMServer.renderToStaticMarkup(element)
|
||||
}
|
||||
|
||||
module.exports = { renderAccountAvatar }
|
||||
@@ -0,0 +1,29 @@
|
||||
# Scenarios: Account Avatar Display - 1, Account Avatar Display - 2
|
||||
#
|
||||
# When the authenticated account has an avatar URL set, the /account page
|
||||
# renders that image instead of only showing the URL as text. When no avatar
|
||||
# URL is set, the account page shows no avatar image. This is a read-only
|
||||
# rendering feature in psf-memo-client: it broadcasts no Memo action and
|
||||
# changes no DB data.
|
||||
Feature: Account Avatar Display
|
||||
|
||||
Background:
|
||||
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
|
||||
Given the wallet has spendable output to pay the transaction fee
|
||||
|
||||
Scenario Outline: Account Avatar Display - 1 the account page displays the avatar image when an avatar URL is set
|
||||
Given I navigate to the path /memo/set-avatar-url
|
||||
When I type an avatar URL with the text "<url>"
|
||||
When I submit the avatar URL
|
||||
Then the app broadcasts an OP_RETURN transaction with the Memo set-profile-picture prefix
|
||||
When I navigate to the path /account
|
||||
Then the account page displays the avatar image with the URL "<url>"
|
||||
|
||||
Examples:
|
||||
| url |
|
||||
| https://example.com/avatar.png |
|
||||
| https://cdn.example.com/pics/me.jpg |
|
||||
|
||||
Scenario: Account Avatar Display - 2 the account page shows no avatar image when no avatar URL is set
|
||||
Given I navigate to the path /account
|
||||
Then the account page does not display an avatar image
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Pure account avatar image component.
|
||||
|
||||
Renders an avatar <img> when a URL is provided and nothing when it is
|
||||
absent. Written in plain React.createElement style so the same module can
|
||||
be used by the JSX components in the browser build and by the acceptance
|
||||
adapter that renders HTML under Node.
|
||||
*/
|
||||
|
||||
const React = require('react')
|
||||
|
||||
function AvatarImage ({ url }) {
|
||||
if (!url) return null
|
||||
|
||||
return React.createElement('img', {
|
||||
src: url,
|
||||
alt: 'Account avatar',
|
||||
className: 'account-avatar-image'
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = AvatarImage
|
||||
@@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import AccountPage from '../../../services/account-page'
|
||||
import AvatarImage from '../../../components/account/avatar-image'
|
||||
import { truncateAddr } from '../../../util'
|
||||
|
||||
function Account (props) {
|
||||
@@ -63,7 +64,7 @@ function Account (props) {
|
||||
|
||||
const displayName = accountPage.getName() || name || truncateAddr(address, 24)
|
||||
const displayBio = accountPage.getBio() || bio || ''
|
||||
const displayAvatarUrl = accountPage.getAvatarUrl() || avatarUrl || ''
|
||||
const displayAvatarUrl = accountPage.getDisplayAvatarUrl(avatarUrl)
|
||||
|
||||
return (
|
||||
<Container className='account-page mt-4'>
|
||||
@@ -91,9 +92,15 @@ function Account (props) {
|
||||
<strong>Bio: </strong>
|
||||
{displayBio || <span className='text-muted'>No bio set</span>}
|
||||
</p>
|
||||
<p className='account-avatar-url'>
|
||||
<strong>Avatar URL: </strong>
|
||||
{displayAvatarUrl || <span className='text-muted'>No avatar URL set</span>}
|
||||
<p className='account-avatar'>
|
||||
<strong>Avatar: </strong>
|
||||
{displayAvatarUrl
|
||||
? (
|
||||
<AvatarImage url={displayAvatarUrl} />
|
||||
)
|
||||
: (
|
||||
<span className='text-muted'>No avatar URL set</span>
|
||||
)}
|
||||
</p>
|
||||
<p className='account-address'>
|
||||
<strong>Address: </strong>
|
||||
|
||||
@@ -53,6 +53,22 @@ class AccountPage {
|
||||
return this._getProfileField('getAvatarUrl')
|
||||
}
|
||||
|
||||
// The avatar URL to display, preferring the injected profile store and
|
||||
// falling back to an optional externally loaded URL (e.g. from memo-db).
|
||||
getDisplayAvatarUrl (fallbackUrl = null) {
|
||||
return this.getAvatarUrl() || fallbackUrl || null
|
||||
}
|
||||
|
||||
// Whether the account page should display an avatar image.
|
||||
hasAvatarImage (fallbackUrl = null) {
|
||||
return this.getDisplayAvatarUrl(fallbackUrl) !== null
|
||||
}
|
||||
|
||||
// The URL for the account page avatar image, or null when none is set.
|
||||
getAvatarImageUrl (fallbackUrl = null) {
|
||||
return this.getDisplayAvatarUrl(fallbackUrl)
|
||||
}
|
||||
|
||||
// Whether the account page exposes a Set Name button.
|
||||
hasSetNameButton () {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Property tests for the account page avatar display logic.
|
||||
|
||||
The unit tests probe a few fixed inputs. These properties cover broad input
|
||||
combinations so the invariants hold everywhere:
|
||||
|
||||
- precedence: getDisplayAvatarUrl prefers the profile store URL, then the
|
||||
fallback URL, then null.
|
||||
- consistency: hasAvatarImage is true exactly when getAvatarImageUrl is
|
||||
non-null, and getAvatarImageUrl always equals getDisplayAvatarUrl.
|
||||
- round trip: getAvatarUrl returns exactly the URL stored in the profile
|
||||
store (or null when none is stored).
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const { seededRandom, forAll } = require('./harness')
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
|
||||
const rng = seededRandom(20260905)
|
||||
|
||||
// A pool of plausible avatar URLs so generated inputs vary.
|
||||
const URL_POOL = [
|
||||
'https://example.com/avatar.png',
|
||||
'https://cdn.example.com/pics/me.jpg',
|
||||
'https://x.io/avatar.png',
|
||||
'https://static.example.org/img/me.webp'
|
||||
]
|
||||
|
||||
// Generate a URL or null.
|
||||
function maybeUrl () {
|
||||
if (rng() < 0.5) return null
|
||||
return URL_POOL[Math.floor(rng() * URL_POOL.length)]
|
||||
}
|
||||
|
||||
function makeProfiles () {
|
||||
const avatarUrls = {}
|
||||
return {
|
||||
setAvatarUrl: (addr, url) => { avatarUrls[addr] = url },
|
||||
getAvatarUrl: (addr) => avatarUrls[addr] || null
|
||||
}
|
||||
}
|
||||
|
||||
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
|
||||
return { walletInfo: { cashAddress: address } }
|
||||
}
|
||||
|
||||
// Build a page with an optional stored profile URL and return it.
|
||||
function makePage (profileUrl) {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
if (profileUrl) profiles.setAvatarUrl(wallet.walletInfo.cashAddress, profileUrl)
|
||||
return page
|
||||
}
|
||||
|
||||
test('getDisplayAvatarUrl prefers the profile URL, then the fallback, then null', async () => {
|
||||
await forAll(
|
||||
(i) => ({ profileUrl: maybeUrl(), fallback: maybeUrl() }),
|
||||
({ profileUrl, fallback }) => {
|
||||
const page = makePage(profileUrl)
|
||||
const expected = profileUrl || fallback || null
|
||||
return page.getDisplayAvatarUrl(fallback) === expected
|
||||
},
|
||||
{ label: 'getDisplayAvatarUrl precedence' }
|
||||
)
|
||||
})
|
||||
|
||||
test('hasAvatarImage and getAvatarImageUrl are consistent with getDisplayAvatarUrl', async () => {
|
||||
await forAll(
|
||||
(i) => ({ profileUrl: maybeUrl(), fallback: maybeUrl() }),
|
||||
({ profileUrl, fallback }) => {
|
||||
const page = makePage(profileUrl)
|
||||
const url = page.getAvatarImageUrl(fallback)
|
||||
return page.hasAvatarImage(fallback) === (url !== null) &&
|
||||
url === page.getDisplayAvatarUrl(fallback)
|
||||
},
|
||||
{ label: 'avatar image consistency' }
|
||||
)
|
||||
})
|
||||
|
||||
test('getAvatarUrl round-trips the stored profile URL', async () => {
|
||||
await forAll(
|
||||
(i) => maybeUrl(),
|
||||
(url) => {
|
||||
const page = makePage(url)
|
||||
return page.getAvatarUrl() === url
|
||||
},
|
||||
{ label: 'getAvatarUrl round trip' }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
Unit tests for the account avatar image component.
|
||||
|
||||
The component renders an avatar image when a URL is provided and renders
|
||||
nothing when the URL is missing, so the account page can display the
|
||||
authenticated user's profile picture.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const AvatarImage = require('../../src/components/account/avatar-image')
|
||||
|
||||
function renderAvatarImage (url) {
|
||||
const element = React.createElement(AvatarImage, { url })
|
||||
return ReactDOMServer.renderToStaticMarkup(element)
|
||||
}
|
||||
|
||||
test('renders null when the URL is undefined', () => {
|
||||
const html = renderAvatarImage(undefined)
|
||||
assert.equal(html, '')
|
||||
})
|
||||
|
||||
test('renders null when the URL is null', () => {
|
||||
const html = renderAvatarImage(null)
|
||||
assert.equal(html, '')
|
||||
})
|
||||
|
||||
test('renders null when the URL is an empty string', () => {
|
||||
const html = renderAvatarImage('')
|
||||
assert.equal(html, '')
|
||||
})
|
||||
|
||||
test('renders an img element with the provided URL', () => {
|
||||
const url = 'https://example.com/avatar.png'
|
||||
const html = renderAvatarImage(url)
|
||||
|
||||
assert.match(html, /<img[^>]+src="https:\/\/example\.com\/avatar\.png"/)
|
||||
})
|
||||
|
||||
test('renders an img element with an accessible alt text', () => {
|
||||
const html = renderAvatarImage('https://example.com/avatar.png')
|
||||
|
||||
assert.match(html, /<img[^>]+alt="Account avatar"/)
|
||||
})
|
||||
|
||||
test('renders an img element with the account avatar class', () => {
|
||||
const html = renderAvatarImage('https://example.com/avatar.png')
|
||||
|
||||
assert.match(html, /<img[^>]+class="account-avatar-image"/)
|
||||
})
|
||||
@@ -145,3 +145,72 @@ test('clickSetAvatarUrl navigates to the set-avatar-url page', () => {
|
||||
|
||||
assert.deepEqual(navigated, [AccountPage.SET_AVATAR_URL_PATH])
|
||||
})
|
||||
|
||||
test('hasAvatarImage returns true when an avatar URL is set', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
profiles.setAvatarUrl(wallet.walletInfo.cashAddress, 'https://example.com/avatar.png')
|
||||
|
||||
assert.equal(page.hasAvatarImage(), true)
|
||||
})
|
||||
|
||||
test('hasAvatarImage returns true when only a fallback URL is provided', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
assert.equal(page.hasAvatarImage('https://fallback.com/avatar.png'), true)
|
||||
})
|
||||
|
||||
test('hasAvatarImage returns false when no avatar URL is set', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
assert.equal(page.hasAvatarImage(), false)
|
||||
})
|
||||
|
||||
test('hasAvatarImage returns false without a wallet', () => {
|
||||
const profiles = makeProfiles()
|
||||
const page = new AccountPage({ profiles })
|
||||
|
||||
assert.equal(page.hasAvatarImage(), false)
|
||||
})
|
||||
|
||||
test('getAvatarImageUrl returns the stored avatar URL', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
profiles.setAvatarUrl(wallet.walletInfo.cashAddress, 'https://example.com/avatar.png')
|
||||
|
||||
assert.equal(page.getAvatarImageUrl(), 'https://example.com/avatar.png')
|
||||
})
|
||||
|
||||
test('getAvatarImageUrl returns the fallback URL when no profile URL is set', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
assert.equal(page.getAvatarImageUrl('https://fallback.com/avatar.png'), 'https://fallback.com/avatar.png')
|
||||
})
|
||||
|
||||
test('getAvatarImageUrl returns null when no avatar URL is set', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
assert.equal(page.getAvatarImageUrl(), null)
|
||||
})
|
||||
|
||||
test('getDisplayAvatarUrl prefers the profile store over the fallback', () => {
|
||||
const profiles = makeProfiles()
|
||||
const wallet = makeWallet()
|
||||
const page = new AccountPage({ wallet, profiles })
|
||||
|
||||
profiles.setAvatarUrl(wallet.walletInfo.cashAddress, 'https://profile.com/avatar.png')
|
||||
|
||||
assert.equal(page.getDisplayAvatarUrl('https://fallback.com/avatar.png'), 'https://profile.com/avatar.png')
|
||||
})
|
||||
|
||||
+21
-11
@@ -366,6 +366,15 @@ that a single user-facing feature may require specs in more than one component.
|
||||
address on `:` and yielded just `"bitcoincash"` — strip the trailing
|
||||
`:<followeePkHash>` suffix via `key.slice(0, key.lastIndexOf(':'))` instead.
|
||||
Spec: `psf-memo-client/specs/mute-feed-filtering.feature`.
|
||||
21. **The recent-feed `total` is now capped, not exact.** Since the
|
||||
feed-query-performance job (`2bcc965`), `GET /posts/recent` computes
|
||||
`total`/`hasMore` from a capped scan of the last `TOTAL_SCAN_CAP` (10)
|
||||
top-level posts rather than a full `postHeights` walk. `total` is therefore
|
||||
`min(actual, TOTAL_SCAN_CAP)` and `hasMore` is only reliable for the first
|
||||
few pages; deep pagination past the cap may report `hasMore: false` even
|
||||
when older posts exist. Specs for the recent feed should assert `total`
|
||||
against the cap (e.g. `10`) and only assert `hasMore` for the first pages.
|
||||
Spec: `psf-memo-db/specs/feed-query-performance.feature`.
|
||||
|
||||
---
|
||||
|
||||
@@ -403,14 +412,15 @@ At the end of each session, update this file:
|
||||
- Note the current `master` HEAD commit.
|
||||
- State the next feature to work on.
|
||||
|
||||
Current `master` HEAD: `3992395` (merged architect's mute-feed-filtering job —
|
||||
muting a profile now hides its content from the viewer's recent feed, topic feed,
|
||||
search results, and notifications. Server-side filtering keyed by the viewer's
|
||||
address passed as a `viewer` query param; shared `loadMutedAddrs`/`isMutedPost`
|
||||
helper in `psf-memo-db/src/adapters/lib/muted-posts.js`. Verified DB 343 unit +
|
||||
40 property + 9 acceptance passing + lint clean; client build OK + 284 unit +
|
||||
24 acceptance passing + lint clean, including the new `mute-feed-filtering`
|
||||
suite).
|
||||
Next action: **spec the feed query performance work** (capped scan for the
|
||||
`total`/`hasMore` computation + per-page reply counting) — see
|
||||
`specs/feature-backlog.md` "Next up: feed query performance".
|
||||
Current `master` HEAD: `2bcc965` (merged architect's feed-query-performance job —
|
||||
`GET /posts/recent` no longer does two full scans per request. Reply counts are
|
||||
computed per returned post (`countRepliesForTxids`) instead of a global
|
||||
`buildReplyCountMap()` scan, and the `total`/`hasMore` computation is a capped
|
||||
scan of the last `TOTAL_SCAN_CAP` (10) top-level posts instead of walking the
|
||||
whole `postHeights` index. `list-recent-posts.js` uses
|
||||
`scanRecentPostTxidsAndCount()` which returns page txids plus a capped total in
|
||||
one bounded scan. Verified DB unit + 10 acceptance passing + lint clean,
|
||||
including the new `feed-query-performance` suite).
|
||||
Next action: **TBD** — current direction is front-end improvements to
|
||||
`psf-memo-client` (UI/UX polish, accessibility, performance, responsiveness,
|
||||
state handling, error surfacing). See `specs/feature-backlog.md`.
|
||||
|
||||
+20
-17
@@ -61,6 +61,16 @@ focus is **front-end improvements** to `psf-memo-client` (the React SPA).
|
||||
of the raw URL; surrounding text is preserved; non-embeddable URLs stay plain
|
||||
text. Client-only rendering feature. Spec:
|
||||
`psf-memo-client/specs/youtube-embed.feature`. Merged to `master` at `b63019c`.
|
||||
- **Feed query performance (2026-09-05):** `GET /posts/recent` no longer does
|
||||
two full scans per request. Reply counts are computed per returned post
|
||||
(`countRepliesForTxids`) instead of a global `buildReplyCountMap()` scan, and
|
||||
the `total`/`hasMore` computation is a capped scan of the last
|
||||
`TOTAL_SCAN_CAP` (10) top-level posts instead of walking the whole
|
||||
`postHeights` index. `list-recent-posts.js` now uses
|
||||
`scanRecentPostTxidsAndCount()` which returns the page txids plus a capped
|
||||
total in one bounded scan. Spec:
|
||||
`psf-memo-db/specs/feed-query-performance.feature`. Merged to `master` at
|
||||
`2bcc965`.
|
||||
|
||||
---
|
||||
|
||||
@@ -137,25 +147,18 @@ Reference: https://memo.sv/protocol (Wayback snapshot 2025-12-15)
|
||||
|
||||
---
|
||||
|
||||
## Next up: feed query performance
|
||||
## Next up: feed query performance — DONE (2026-09-05)
|
||||
|
||||
`GET /posts/recent` (and the other paginated feeds) is slow at 1.3M posts because
|
||||
`list-recent-posts.js` does two full scans on every request:
|
||||
`GET /posts/recent` previously did two full scans per request:
|
||||
`countTopLevelPosts()` walked the entire `postHeights` index for
|
||||
`total`/`hasMore`, and `buildReplyCountMap()` scanned all `postChildren` entries.
|
||||
Both are now bounded: reply counts are per-page (`countRepliesForTxids`) and the
|
||||
total scan is capped to the last `TOTAL_SCAN_CAP` (10) top-level posts. Merged
|
||||
to `master` at `2bcc965`.
|
||||
|
||||
- `countTopLevelPosts()` iterates the ENTIRE `postHeights` index to compute the
|
||||
`total`/`hasMore` pagination field.
|
||||
- `buildReplyCountMap()` scans ALL `postChildren` entries to build a global
|
||||
reply-count map.
|
||||
|
||||
Planned optimization (decision: **capped scan**, keep it simple):
|
||||
|
||||
- Replace the global `buildReplyCountMap()` with per-page-txid reply counting
|
||||
(like `countLikesForTxids` already does) — only count replies for the ~50
|
||||
posts on the page.
|
||||
- Cap the `total` scan to the last N posts / last N blocks so `hasMore` still
|
||||
works for the first pages without walking all 1.3M entries.
|
||||
|
||||
Affected components: `psf-memo-db` (feed use cases + post-query adapter).
|
||||
Next feature: TBD — current direction is front-end improvements to
|
||||
`psf-memo-client` (UI/UX polish, accessibility, performance, responsiveness,
|
||||
state handling, error surfacing).
|
||||
|
||||
## Notes for future cycles
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Engineering Rules
|
||||
|
||||
## Startup Tools
|
||||
- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed `github.com/unclebob/...` repositories and get each one ready to run.
|
||||
- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed repositories and get each one ready to run.
|
||||
- Resolve each listed repository at its latest available upstream version before installing or building it.
|
||||
- Do not rely on stale cached, vendored, or preinstalled copies when a fresh GitHub install/build is possible in the current environment.
|
||||
- Language tool table:
|
||||
@@ -11,10 +11,7 @@
|
||||
- Node.js JavaScript: install with `npm install`; mutation `github.com/FullStack-Agents/mutate4javascript`, CRAP `github.com/FullStack-Agents/crap4javascript`, DRY `github.com/FullStack-Agents/dry4javascript`.
|
||||
|
||||
## Language Defaults
|
||||
- For Clojure projects, prefer Babashka where possible.
|
||||
- For Clojure projects, prefer Speclj for unit and behavior tests.
|
||||
- For Clojure or Babashka projects using Speclj, use `github.com/unclebob/speclj-structure-check` to validate test syntax. If a Speclj spec file changed, run the structure check before executing the relevant test command.
|
||||
- For Java projects, avoid using Maven to run tests; build dedicated test runners and run those instead.
|
||||
- For JavaScript projects, prefer these npm dev dependencies: mocha for test runner, chai for assertion library, standard for linting, c8 for code coverage, semantic-release for version control.
|
||||
|
||||
## Design And Testability
|
||||
- Work in small, reviewable increments.
|
||||
|
||||
Reference in New Issue
Block a user