diff --git a/tools/brain-retro-analyzer.mjs b/tools/brain-retro-analyzer.mjs index b4c8feb9..29f1990c 100644 --- a/tools/brain-retro-analyzer.mjs +++ b/tools/brain-retro-analyzer.mjs @@ -199,6 +199,73 @@ function latencyBucket(latency) { return 'very_slow'; } +// Pass 3 helpers (project-brain-factor-analysis-4passes). +function promptLengthBucket(n) { + const v = Number(n); + if (!Number.isFinite(v) || v <= 0) return 'null'; + if (v < 100) return 'short'; + if (v < 1000) return 'medium'; + if (v < 2500) return 'long'; + return 'huge'; +} + +function timeOfDayBucket(iso) { + // Reject null / undefined / empty BEFORE Date construction: `new Date(null)` + // is the epoch (1970-01-01), not NaN — would falsely bucket missing + // timestamps as 'night'. + if (iso == null || iso === '') return 'null'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return 'null'; + const h = d.getUTCHours(); + if (h < 6) return 'night'; + if (h < 12) return 'morning'; + if (h < 18) return 'afternoon'; + return 'evening'; +} + +const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +function dayOfWeekLabel(iso) { + if (iso == null || iso === '') return 'null'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return 'null'; + return WEEKDAY_NAMES[d.getUTCDay()]; +} + +function interPromptGapBucket(min) { + const v = Number(min); + if (!Number.isFinite(v) || v < 0) return 'null'; + if (v < 1) return '<1m'; + if (v < 10) return '1-10m'; + if (v < 60) return '10-60m'; + return '60m+'; +} + +function fileTypeMain(dist) { + if (!dist || typeof dist !== 'object') return 'none'; + const entries = Object.entries(dist).filter(([, n]) => Number(n) > 0); + if (entries.length === 0) return 'none'; + let maxN = 0; + for (const [, n] of entries) if (n > maxN) maxN = n; + const winners = entries.filter(([, n]) => n === maxN); + if (winners.length > 1) return 'mixed'; + return winners[0][0]; +} + +function eventToolCount(events, toolName) { + if (!Array.isArray(events)) return 0; + for (const ev of events) { + if (ev && ev.kind === 'tool_summary' && ev.counts) { + return Number(ev.counts[toolName]) || 0; + } + } + return 0; +} + +function countBucket012(n) { + const v = Number(n) || 0; + return v === 0 ? '0' : v === 1 ? '1' : '2+'; +} + const FACTOR_FNS = { decision_provenance: (e) => (e.decision_provenance || {}).kind || 'unknown', economy_level: (e) => String((e.environment || {}).economy_level ?? 'null'), @@ -222,6 +289,17 @@ const FACTOR_FNS = { // Pass 2 — classifier-metric axes (project-brain-factor-analysis-4passes): latency_bucket: (e) => latencyBucket((e.classifier_output || {}).latency_ms), error_type: (e) => (e.classifier_output || {}).llm_error || 'null', + // Pass 3 — dynamics axes (project-brain-factor-analysis-4passes): + prompt_length_bucket: (e) => promptLengthBucket((e.task_meta || {}).prompt_length_chars), + time_of_day_bucket: (e) => timeOfDayBucket((e.timestamps || {}).started_at), + day_of_week: (e) => dayOfWeekLabel((e.timestamps || {}).started_at), + inter_prompt_gap_bucket: (e) => interPromptGapBucket(e._interPromptGapMin), + mcp_server_used: (e) => (((e.task_meta || {}).mcp_servers_used || []).length > 0 ? 'any' : 'none'), + file_type_main: (e) => fileTypeMain((e.task_meta || {}).file_type_distribution), + skill_invocations_bucket: (e) => countBucket012(eventToolCount(e.events, 'Skill')), + subagent_spawns_bucket: (e) => countBucket012( + eventToolCount(e.events, 'Agent') + eventToolCount(e.events, 'Task'), + ), }; /** Factor matrix: rows = factor values, columns = outcome distribution (spec §6). */ @@ -262,6 +340,16 @@ export function analyze(episodes, options = {}) { for (const eps of bySessionSorted(normal).values()) { eps.forEach((episode, i) => { episode._inferredOutcome = inferOutcome(episode, eps[i + 1]); + // Pass 3 — inter-prompt gap (project-brain-factor-analysis-4passes). + // Cross-episode signal: minutes between this episode's start and the + // previous (same-session) episode's end. First episode of a session + // has no prev → stays undefined → bucket 'null'. + if (i > 0) { + const prevEnded = (eps[i - 1].timestamps || {}).ended_at; + const curStarted = (episode.timestamps || {}).started_at; + const ms = new Date(curStarted) - new Date(prevEnded); + if (Number.isFinite(ms) && ms >= 0) episode._interPromptGapMin = ms / 60000; + } }); } const classificationMap = options.classificationMap || {}; diff --git a/tools/brain-retro-analyzer.test.mjs b/tools/brain-retro-analyzer.test.mjs index e81371b5..855746ef 100644 --- a/tools/brain-retro-analyzer.test.mjs +++ b/tools/brain-retro-analyzer.test.mjs @@ -517,6 +517,127 @@ describe('buildFactorMatrix — Pass 1 cheap axes (project-brain-factor-analysis }); }); +describe('buildFactorMatrix — Pass 3 dynamics axes (project-brain-factor-analysis-4passes)', () => { + it('prompt_length_bucket axis: short / medium / long / huge / null', () => { + const m = buildFactorMatrix([ + { ...ep(), _inferredOutcome: 'success', task_meta: { prompt_length_chars: 42 } }, + { ...ep(), _inferredOutcome: 'success', task_meta: { prompt_length_chars: 300 } }, + { ...ep(), _inferredOutcome: 'rework', task_meta: { prompt_length_chars: 1200 } }, + { ...ep(), _inferredOutcome: 'blocked', task_meta: { prompt_length_chars: 5000 } }, + { ...ep(), _inferredOutcome: 'unknown', task_meta: undefined }, + ]); + expect(m.prompt_length_bucket.short.success).toBe(1); + expect(m.prompt_length_bucket.medium.success).toBe(1); + expect(m.prompt_length_bucket.long.rework).toBe(1); + expect(m.prompt_length_bucket.huge.blocked).toBe(1); + expect(m.prompt_length_bucket.null.unknown).toBe(1); + }); + + it('time_of_day_bucket axis derived from timestamps.started_at UTC hour', () => { + const at = (iso) => ({ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: iso } }); + const m = buildFactorMatrix([ + at('2026-05-25T03:00:00Z'), // night (0-5) + at('2026-05-25T09:00:00Z'), // morning (6-11) + at('2026-05-25T14:00:00Z'), // afternoon (12-17) + at('2026-05-25T20:00:00Z'), // evening (18-23) + ]); + expect(m.time_of_day_bucket.night.success).toBe(1); + expect(m.time_of_day_bucket.morning.success).toBe(1); + expect(m.time_of_day_bucket.afternoon.success).toBe(1); + expect(m.time_of_day_bucket.evening.success).toBe(1); + }); + + it('day_of_week axis: Mon..Sun derived from started_at UTC', () => { + // 2026-05-25 is a Monday (UTC). + const m = buildFactorMatrix([ + { ...ep(), _inferredOutcome: 'success', timestamps: { started_at: '2026-05-25T10:00:00Z' } }, // Mon + { ...ep(), _inferredOutcome: 'success', timestamps: { started_at: '2026-05-27T10:00:00Z' } }, // Wed + { ...ep(), _inferredOutcome: 'unknown', timestamps: { started_at: null } }, + ]); + expect(m.day_of_week.Mon.success).toBe(1); + expect(m.day_of_week.Wed.success).toBe(1); + expect(m.day_of_week.null.unknown).toBe(1); + }); + + it('inter_prompt_gap_bucket axis: gap between current and previous episode of same session', () => { + const eps = [ + { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T10:00:00Z', ended_at: '2026-05-25T10:05:00Z' }, + prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' }, + environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] }, + // 2-minute gap → bucket "1-10m" + { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T10:07:00Z', ended_at: '2026-05-25T10:10:00Z' }, + prompt_signal: 'correction', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' }, + environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] }, + // 80-minute gap → bucket "60m+" + { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T11:30:00Z', ended_at: '2026-05-25T11:35:00Z' }, + prompt_signal: 'approval', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' }, + environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] }, + ]; + const result = analyze(eps); + expect(result.factorMatrix.inter_prompt_gap_bucket).toBeDefined(); + // First episode has no previous → bucket 'null'. + expect(result.factorMatrix.inter_prompt_gap_bucket.null).toBeDefined(); + expect(result.factorMatrix.inter_prompt_gap_bucket['1-10m']).toBeDefined(); + expect(result.factorMatrix.inter_prompt_gap_bucket['60m+']).toBeDefined(); + }); + + it('mcp_server_used axis: any / none (presence of any mcp_servers_used entry)', () => { + const m = buildFactorMatrix([ + { ...ep(), _inferredOutcome: 'success', task_meta: { mcp_servers_used: ['github'] } }, + { ...ep(), _inferredOutcome: 'success', task_meta: { mcp_servers_used: [] } }, + { ...ep(), _inferredOutcome: 'unknown' /* missing */ }, + ]); + expect(m.mcp_server_used.any.success).toBe(1); + expect(m.mcp_server_used.none.success).toBe(1); + expect(m.mcp_server_used.none.unknown).toBe(1); + }); + + it('file_type_main axis: dominant path category from file_type_distribution', () => { + const m = buildFactorMatrix([ + { ...ep(), _inferredOutcome: 'success', task_meta: { file_type_distribution: { src: 3, test: 1, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, + { ...ep(), _inferredOutcome: 'rework', task_meta: { file_type_distribution: { src: 0, test: 4, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, + { ...ep(), _inferredOutcome: 'success', task_meta: { file_type_distribution: { src: 2, test: 2, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, // tie → mixed + { ...ep(), _inferredOutcome: 'unknown', task_meta: { file_type_distribution: { src: 0, test: 0, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, // empty → none + { ...ep(), _inferredOutcome: 'unknown' /* missing */ }, + ]); + expect(m.file_type_main.src.success).toBe(1); + expect(m.file_type_main.test.rework).toBe(1); + expect(m.file_type_main.mixed.success).toBe(1); + expect(m.file_type_main.none.unknown).toBe(2); // empty + missing + }); + + it('skill_invocations_bucket axis: 0 / 1 / 2+ from events tool_summary.Skill', () => { + const m = buildFactorMatrix([ + { ...ep(), _inferredOutcome: 'success', events: [] }, + { ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Skill: 1, Read: 5 } }] }, + { ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Skill: 3 } }] }, + ]); + expect(m.skill_invocations_bucket['0'].success).toBe(1); + expect(m.skill_invocations_bucket['1'].success).toBe(1); + expect(m.skill_invocations_bucket['2+'].success).toBe(1); + }); + + it('subagent_spawns_bucket axis: 0 / 1 / 2+ from events tool_summary.Agent (or Task)', () => { + const m = buildFactorMatrix([ + { ...ep(), _inferredOutcome: 'success', events: [] }, + { ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Agent: 1 } }] }, + { ...ep(), _inferredOutcome: 'rework', events: [{ kind: 'tool_summary', counts: { Agent: 4 } }] }, + ]); + expect(m.subagent_spawns_bucket['0'].success).toBe(1); + expect(m.subagent_spawns_bucket['1'].success).toBe(1); + expect(m.subagent_spawns_bucket['2+'].rework).toBe(1); + }); + + it('all 8 Pass 3 axes are present via analyze() on a minimal v2 episode', () => { + const result = analyze([ep()]); + for (const axis of ['prompt_length_bucket', 'time_of_day_bucket', 'day_of_week', + 'inter_prompt_gap_bucket', 'mcp_server_used', 'file_type_main', + 'skill_invocations_bucket', 'subagent_spawns_bucket']) { + expect(result.factorMatrix, `axis ${axis} missing`).toHaveProperty(axis); + } + }); +}); + describe('buildFactorMatrix — Pass 2 classifier-metric axes', () => { it('latency_bucket axis: fast / medium / slow / very_slow / null', () => { const m = buildFactorMatrix([ diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 2616d93c..2215c5a9 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -368,6 +368,73 @@ function collectToolResultText(turn) { return parts.join('\n'); } +// Pass 3 — path-pattern classifier (project-brain-factor-analysis-4passes). +// Returns one of: test / config / spec / norm / data / src / other. +// Priority order matters (test before src, norm before src, etc). +export function classifyFilePath(path) { + if (!path) return 'other'; + const p = String(path).replace(/\\/g, '/'); + const base = p.split('/').pop() || p; + + // 1. tests + if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base)) return 'test'; + if (/(?:^|\/)(?:tests?|spec)\//i.test(p)) return 'test'; + + // 2. normative documents (CLAUDE.md / Pravila / PSR / Tooling / Открытые_вопросы / memory store). + if (/(?:^|\/)CLAUDE\.md$/i.test(p)) return 'norm'; + if (/(?:^|\/)Pravila_raboty_Claude[^/]*\.md$/i.test(p)) return 'norm'; + if (/(?:^|\/)Plugin_stack_rules[^/]*\.md$/i.test(p)) return 'norm'; + if (/(?:^|\/)Tooling[^/]*\.md$/i.test(p)) return 'norm'; + if (/(?:^|\/)Открытые_вопросы[^/]*\.md$/i.test(p)) return 'norm'; + if (/(?:^|\/)MEMORY\.md$/i.test(p)) return 'norm'; + if (/\/memory\/[^/]+\.md$/i.test(p)) return 'norm'; + + // 3. spec / plan + if (/(?:^|\/)docs\/superpowers\/(?:specs|plans)\//i.test(p)) return 'spec'; + + // 4. config + if (/(?:^|\/)\.env(?:\.|$)/i.test(p)) return 'config'; + if (/(?:^|\/)(?:package|composer|tsconfig)\.json$/i.test(base)) return 'config'; + if (/\.config\.[a-z0-9]+$/i.test(base)) return 'config'; + if (/(?:^|\/)(?:lefthook|\.eslintrc|cspell|stylelint|prettier|pint)[^/]*\.(?:yml|yaml|json|cjs|mjs|js|toml)$/i.test(p)) return 'config'; + + // 5. data + if (/\.(?:jsonl|csv|sql|sqlite)$/i.test(base)) return 'data'; + + // 6. src + if (/(?:^|\/)(?:app|tools|resources|src|lib|db\/migrations)\//i.test(p)) return 'src'; + + return 'other'; +} + +const FILE_TYPE_CATEGORIES = ['src', 'test', 'config', 'spec', 'norm', 'data', 'other']; + +export function extractFileTypeDistribution(files) { + const dist = Object.fromEntries(FILE_TYPE_CATEGORIES.map((c) => [c, 0])); + for (const f of files || []) { + dist[classifyFilePath(f)] += 1; + } + return dist; +} + +// Pass 3 — MCP server fingerprint. tool_use[].name follows +// `mcp____` where may itself contain single underscores +// (e.g. mcp__plugin_brand-voice_box__authenticate). Non-greedy match stops at +// the FIRST `__` after the prefix so multi-word server names land whole. +export function extractMcpServers(turn) { + const servers = new Set(); + for (const e of turn || []) { + const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; + for (const b of content) { + if (b && b.type === 'tool_use' && typeof b.name === 'string') { + const m = b.name.match(/^mcp__(.+?)__/); + if (m) servers.add(m[1]); + } + } + } + return [...servers]; +} + /** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */ export function extractTaskSize(turn) { let tool_calls = 0; @@ -853,6 +920,18 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option environment: { ..._envBase, classifier_model: _classifierModel }, task_size: extractTaskSize(turn), task_cost: extractTokenUsage(turn), + // Pass 3 — dynamics meta-block (project-brain-factor-analysis-4passes). + // prompt_length_chars: strlen of first user prompt (engagement / clarity proxy). + // mcp_servers_used: unique mcp____* fingerprints in this turn. + // file_type_distribution: per-bucket counts of unique paths touched. + task_meta: (() => { + const ts = extractTaskSize(turn); + return { + prompt_length_chars: typeof prompt === 'string' ? prompt.length : 0, + mcp_servers_used: extractMcpServers(turn), + file_type_distribution: extractFileTypeDistribution(ts.files), + }; + })(), classifier_output: _classifierOutput, degraded_mode: _degraded, primary_rationale: (() => { diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 51ba7359..14a9d3a4 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -12,6 +12,9 @@ import { extractLastUserPromptText, classifyTask, extractTokenUsage, + extractMcpServers, + extractFileTypeDistribution, + classifyFilePath, } from './observer-transcript-parser.mjs'; // Build a JSONL transcript string from entry objects. @@ -1813,3 +1816,110 @@ describe('parseTranscript — schema v4.3 write-block fields (phase 3 deferred # expect(cost.reviewer_subagent_usd).toBe(0); }); }); + +describe('classifyFilePath — Pass 3 path-pattern bucketing (project-brain-factor-analysis-4passes)', () => { + it('classifies test files', () => { + expect(classifyFilePath('tools/foo.test.mjs')).toBe('test'); + expect(classifyFilePath('app/tests/Feature/X.php')).toBe('test'); + expect(classifyFilePath('resources/js/foo.spec.ts')).toBe('test'); + }); + it('classifies config files', () => { + expect(classifyFilePath('package.json')).toBe('config'); + expect(classifyFilePath('vite.config.ts')).toBe('config'); + expect(classifyFilePath('lefthook.yml')).toBe('config'); + expect(classifyFilePath('.env')).toBe('config'); + expect(classifyFilePath('tsconfig.json')).toBe('config'); + }); + it('classifies spec/plan files under docs/superpowers/', () => { + expect(classifyFilePath('docs/superpowers/specs/x.md')).toBe('spec'); + expect(classifyFilePath('docs/superpowers/plans/x.md')).toBe('spec'); + }); + it('classifies normative documents', () => { + expect(classifyFilePath('CLAUDE.md')).toBe('norm'); + expect(classifyFilePath('c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md')).toBe('norm'); + expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md')).toBe('norm'); + expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm'); + expect(classifyFilePath('docs/Tooling_v8_3.md')).toBe('norm'); + expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md')).toBe('norm'); + }); + it('classifies data files', () => { + expect(classifyFilePath('docs/observer/episodes-2026-05.jsonl')).toBe('data'); + expect(classifyFilePath('db/seed.csv')).toBe('data'); + expect(classifyFilePath('db/schema.sql')).toBe('data'); + }); + it('classifies app/tools source under src', () => { + expect(classifyFilePath('app/Http/Controllers/X.php')).toBe('src'); + expect(classifyFilePath('tools/router-classifier.mjs')).toBe('src'); + expect(classifyFilePath('resources/js/views/Dashboard.vue')).toBe('src'); + }); + it('returns other for paths that fit no category', () => { + expect(classifyFilePath('some-random-binary.png')).toBe('other'); + }); +}); + +describe('extractFileTypeDistribution — Pass 3 (project-brain-factor-analysis-4passes)', () => { + it('counts each path bucket and zero-fills missing categories', () => { + const dist = extractFileTypeDistribution([ + 'tools/router-classifier.mjs', + 'tools/router-classifier.test.mjs', + 'docs/superpowers/specs/x.md', + 'CLAUDE.md', + ]); + expect(dist.src).toBe(1); + expect(dist.test).toBe(1); + expect(dist.spec).toBe(1); + expect(dist.norm).toBe(1); + expect(dist.config).toBe(0); + expect(dist.data).toBe(0); + expect(dist.other).toBe(0); + }); + it('returns all-zero distribution for empty input', () => { + const dist = extractFileTypeDistribution([]); + for (const v of Object.values(dist)) expect(v).toBe(0); + }); +}); + +describe('extractMcpServers — Pass 3 (project-brain-factor-analysis-4passes)', () => { + it('extracts unique mcp____* prefixes from tool_use entries', () => { + const turn = [ + assistantTurn([ + { type: 'tool_use', id: 't1', name: 'mcp__github__list_issues', input: {} }, + { type: 'tool_use', id: 't2', name: 'mcp__github__get_pr', input: {} }, + { type: 'tool_use', id: 't3', name: 'mcp__playwright__browser_click', input: {} }, + { type: 'tool_use', id: 't4', name: 'Read', input: { file_path: 'a.txt' } }, + ], '2026-05-25T10:00:00Z'), + ]; + expect(extractMcpServers(turn).sort()).toEqual(['github', 'playwright']); + }); + it('returns empty array when no mcp tools used', () => { + const turn = [assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a' } }], '2026-05-25T10:00:00Z')]; + expect(extractMcpServers(turn)).toEqual([]); + }); +}); + +describe('parseTranscript — Pass 3 task_meta block (project-brain-factor-analysis-4passes)', () => { + it('includes prompt_length_chars / mcp_servers_used / file_type_distribution under task_meta', () => { + const t = jsonl([ + userPrompt('добавь функцию X в файл tools/router-classifier.mjs', '2026-05-25T10:00:00Z'), + assistantTurn([ + { type: 'tool_use', id: 't1', name: 'mcp__github__get_pr', input: {} }, + { type: 'tool_use', id: 't2', name: 'Read', input: { file_path: 'tools/router-classifier.mjs' } }, + { type: 'tool_use', id: 't3', name: 'Edit', input: { file_path: 'tools/router-classifier.test.mjs', old_string: 'a', new_string: 'b' } }, + ], '2026-05-25T10:01:00Z'), + ]); + const ep = parseTranscript(t); + expect(ep.task_meta).toBeDefined(); + expect(ep.task_meta.prompt_length_chars).toBe('добавь функцию X в файл tools/router-classifier.mjs'.length); + expect(ep.task_meta.mcp_servers_used).toEqual(['github']); + expect(ep.task_meta.file_type_distribution.src).toBe(1); + expect(ep.task_meta.file_type_distribution.test).toBe(1); + }); + + it('task_meta is present even on empty transcript (null-safe defaults)', () => { + const ep = parseTranscript(''); + expect(ep.task_meta).toBeDefined(); + expect(ep.task_meta.prompt_length_chars).toBe(0); + expect(ep.task_meta.mcp_servers_used).toEqual([]); + expect(ep.task_meta.file_type_distribution.other).toBe(0); + }); +});