Add PM extension (Projects, Work Logs, Comments) + Quill rich-text UI
Projects: Brick-backed model with list/detail screens and nav entry. Work Logs: per-contributor time ledger, assignee running-clock + helper logged minutes with Hours/Minutes input; pause-after-save and helper-deduct prompts. Comments: threaded comments with file attachments and Quill composer. Detail screen: tabbed layout (Work Log / Comments / Activity), editable title + description via edit dialog, Quill-rendered descriptions with legacy plain-text fallback. Create dialog: fixed-width (480px max), Quill description editor. Shared QuillFieldEditor + QuillReadOnly widgets extracted for reuse. RLS: hardened activity-log actor binding, immutable creator_id/ created_at triggers, SECURITY INVOKER visibility helper, append-only work-log and scoped comment policies. Brick migration for project_id FK on programmer_tasks. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_task_work_log.model.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:tasq/utils/programmer_task_ledger.dart';
|
||||
import 'package:tasq/utils/task_duration.dart';
|
||||
|
||||
ProgrammerTaskWorkLog _log(String author, int? minutes) => ProgrammerTaskWorkLog(
|
||||
id: 'l-$author-$minutes',
|
||||
taskId: 't1',
|
||||
authorId: author,
|
||||
description: 'work',
|
||||
minutes: minutes,
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
group('computeTaskLedger', () {
|
||||
test('assignee only (no work logs)', () {
|
||||
final ledger = computeTaskLedger(
|
||||
assigneeId: 'a',
|
||||
assigneeDuration: const Duration(hours: 1),
|
||||
workLogs: const [],
|
||||
);
|
||||
expect(ledger.assignee?.duration, const Duration(hours: 1));
|
||||
expect(ledger.helpers, isEmpty);
|
||||
expect(ledger.total, const Duration(hours: 1));
|
||||
});
|
||||
|
||||
test('sums helper minutes per person; assignee notes excluded', () {
|
||||
final ledger = computeTaskLedger(
|
||||
assigneeId: 'a',
|
||||
assigneeDuration: const Duration(minutes: 60),
|
||||
workLogs: [
|
||||
_log('h1', 30),
|
||||
_log('h1', 15),
|
||||
_log('h2', 20),
|
||||
_log('a', null), // assignee progress note — no minutes, excluded
|
||||
_log('a', 99), // even if an assignee row had minutes, exclude it
|
||||
],
|
||||
);
|
||||
expect(ledger.assignee?.duration, const Duration(minutes: 60));
|
||||
final byUser = {for (final e in ledger.helpers) e.userId: e.duration};
|
||||
expect(byUser['h1'], const Duration(minutes: 45));
|
||||
expect(byUser['h2'], const Duration(minutes: 20));
|
||||
expect(byUser.containsKey('a'), isFalse);
|
||||
expect(ledger.total, const Duration(minutes: 60 + 45 + 20));
|
||||
});
|
||||
|
||||
test('helpers sorted by descending duration', () {
|
||||
final ledger = computeTaskLedger(
|
||||
assigneeId: 'a',
|
||||
assigneeDuration: Duration.zero,
|
||||
workLogs: [_log('h1', 10), _log('h2', 40)],
|
||||
);
|
||||
expect(ledger.helpers.first.userId, 'h2');
|
||||
});
|
||||
|
||||
test('no assignee id → assignee entry is null', () {
|
||||
final ledger = computeTaskLedger(
|
||||
assigneeId: null,
|
||||
assigneeDuration: const Duration(hours: 5),
|
||||
workLogs: [_log('h1', 30)],
|
||||
);
|
||||
expect(ledger.assignee, isNull);
|
||||
expect(ledger.total, const Duration(minutes: 30));
|
||||
});
|
||||
});
|
||||
|
||||
group('computeEffectiveDuration adjustment', () {
|
||||
final base = DateTime.utc(2026, 1, 1, 9);
|
||||
test('subtracts the adjustment', () {
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: base,
|
||||
endAt: base.add(const Duration(hours: 2)),
|
||||
events: const [],
|
||||
adjustment: const Duration(minutes: 30),
|
||||
);
|
||||
expect(worked, const Duration(hours: 1, minutes: 30));
|
||||
});
|
||||
|
||||
test('clamps to zero when the adjustment exceeds worked time', () {
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: base,
|
||||
endAt: base.add(const Duration(minutes: 20)),
|
||||
events: const [],
|
||||
adjustment: const Duration(hours: 1),
|
||||
);
|
||||
expect(worked, Duration.zero);
|
||||
});
|
||||
|
||||
test('default adjustment is zero (existing behavior unchanged)', () {
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: base,
|
||||
endAt: base.add(const Duration(hours: 1)),
|
||||
events: const [],
|
||||
);
|
||||
expect(worked, const Duration(hours: 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/providers/programmer_task_comments_provider.dart';
|
||||
import 'package:tasq/providers/programmer_task_work_logs_provider.dart';
|
||||
import 'package:tasq/providers/programmer_tasks_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/programmer_task_actions.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
@@ -31,6 +33,8 @@ class _FakeClient {
|
||||
final Map<String, List<Map<String, dynamic>>> tables = {
|
||||
'programmer_tasks': [],
|
||||
'programmer_task_activity_logs': [],
|
||||
'programmer_task_work_logs': [],
|
||||
'programmer_task_comments': [],
|
||||
};
|
||||
|
||||
_FakeAuth get auth => _FakeAuth(userId);
|
||||
@@ -459,4 +463,79 @@ void main() {
|
||||
expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.queued);
|
||||
});
|
||||
});
|
||||
|
||||
group('projects, adjustments & collaboration', () {
|
||||
test('createTask carries project_id', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
projectId: 'proj-1',
|
||||
);
|
||||
expect(_task(fake, id)['project_id'], 'proj-1');
|
||||
});
|
||||
|
||||
test('setProject updates project_id and logs an update', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
await c.setProject(taskId: id, projectId: 'proj-9');
|
||||
expect(_task(fake, id)['project_id'], 'proj-9');
|
||||
expect(
|
||||
_logs(fake).any((l) =>
|
||||
l['action_type'] == 'updated' &&
|
||||
(l['meta']?['fields'] as List?)?.contains('project_id') == true),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('addAdjustment records a signed adjustment log', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
await c.addAdjustment(
|
||||
taskId: id,
|
||||
seconds: 1800,
|
||||
reason: 'helped',
|
||||
sourceTaskId: 'other',
|
||||
);
|
||||
final adj = _logs(fake).firstWhere((l) => l['action_type'] == 'adjustment');
|
||||
expect(adj['meta']['seconds'], 1800);
|
||||
expect(adj['meta']['reason'], 'helped');
|
||||
expect(adj['meta']['source_task_id'], 'other');
|
||||
});
|
||||
|
||||
test('addWorkLog stores helper minutes and omits them for the assignee',
|
||||
() async {
|
||||
final fake = _FakeClient('u1');
|
||||
final wl = ProgrammerTaskWorkLogsController(fake as dynamic);
|
||||
await wl.addWorkLog(taskId: 't1', description: 'helped', minutes: 30);
|
||||
await wl.addWorkLog(taskId: 't1', description: 'progress'); // assignee note
|
||||
|
||||
final rows = fake.tables['programmer_task_work_logs']!;
|
||||
expect(rows.length, 2);
|
||||
expect(rows[0]['author_id'], 'u1');
|
||||
expect(rows[0]['minutes'], 30);
|
||||
expect(rows[1].containsKey('minutes'), isFalse); // null minutes omitted
|
||||
});
|
||||
|
||||
test('addComment stores body + author with empty attachments', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final cc = ProgrammerTaskCommentsController(fake as dynamic);
|
||||
await cc.addComment(taskId: 't1', body: 'looks good');
|
||||
|
||||
final rows = fake.tables['programmer_task_comments']!;
|
||||
expect(rows.length, 1);
|
||||
expect(rows.first['author_id'], 'u1');
|
||||
expect(rows.first['body'], 'looks good');
|
||||
expect(rows.first['attachments'], isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user