← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/bug728370-nondirect-subs into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/bug728370-nondirect-subs into lp:launchpad with lp:~danilo/launchpad/bug728370 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~danilo/launchpad/bug728370-nondirect-subs/+merge/57293

= Descriptions for non-direct subscriptions =

This introduces code to render appropriate messages based on the types of subscriptions you have for the 'unsubscribe-in-anger' story (which means, you are getting email from a certain bug, and want it to stop).

Atm, the branch only provides low-level JS code to get a subscription reason and variables to be (potentially) replaced in those reason strings, and doesn't tie it up with anything.

It is based on already landed code for bug 728370 to expose all the needed data to JS in LP.cache.bug_subscription_info, which we make direct use of.

== Implementation details ==

This is my first real TDD JS branch.  This means that there was plenty of refactoring along the way.  It is also incomplete in the sense that it doesn't really provide any value for the user.  However, it is pretty big (1250 lines of diff) that I want it reviewed separately.  Landing it will allow others to more easily collaborate on continuing work, and shouldn't affect anything otherwise.

Generally, we walk through LP.cache.subscription_info which is a container for several categories of subscriptions:

  - direct
  - as assignee
  - from duplicates
  - as project owner (implicit bug supervisor)

Each of those contains several lists of subscriptions by type:

  - personal
  - as team member
  - as team admin

(split because they will provide different 'unsubscribe actions').

This branch doesn't construct this data, it only uses it to return a list of subscriptions as records of the form:

  {
     reason: 'textual reason for a subscription with {vars} like this',
     vars: {
        team: ...,
        teams: ...,
        duplicate_bug: ...,
        duplicate_bugs: ...,
        pillar: ...,
     }
  }

Some of the bits in strings are obviously missing (like {pillar_type} variable) but I want to tackle that as we attempt to use them.

Some of the decisions about how we group/collate subscriptions are relatively arbitrary: we could have kept all of them as separate ones, but I followed what Gary has spent some time thinking through and used his constructed textual messages with only slight modifications.

== Tests ==

lib/lp/bugs/javascript/tests/test_subscription.html

== Demo and Q/A ==

Watch the JS console on:

  https://bugs.launchpad.dev/firefox/+bug/1/+subscriptions

It will log two objects: the actual LP.cache.bug_subscription_info and a list of all non-direct subscription descriptions.

To facilitate the demo, add a few subscriptions:

 - directly subscribe to bug 1
 - subscribe one of your teams you are a member of
 - subscribe one of your teams you are an admin of
 - make bugs 2 and 3 duplicates of bug 1
 - subscribe personally/as-team-member/as-team-admin to one of those
   duplicates
 - make yourself/your team the assignee on one or several bug tasks

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/javascript/subscription.js
  lib/lp/bugs/javascript/tests/test_subscription.html
  lib/lp/bugs/javascript/tests/test_subscription.js
  lib/lp/bugs/templates/bug-subscription-list.pt
-- 
https://code.launchpad.net/~danilo/launchpad/bug728370-nondirect-subs/+merge/57293
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/bug728370-nondirect-subs into lp:launchpad.
=== added file 'lib/lp/bugs/javascript/subscription.js'
--- lib/lp/bugs/javascript/subscription.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/subscription.js	2011-04-12 09:44:39 +0000
@@ -0,0 +1,433 @@
+/* Copyright 2011 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Provide information and actions on all bug subscriptions a person holds.
+ *
+ * @module bugs
+ * @submodule subscription
+ */
+
+YUI.add('lp.bugs.subscription', function(Y) {
+
+var namespace = Y.namespace('lp.bugs.subscription');
+
+/**
+ * These are the descriptions strings of what might be the cause of you
+ * getting an email.
+ */
+
+var _BECAUSE_YOU_ARE = 'You receive emails about this bug because you are ';
+
+/**
+ * Store complete subscription 'reasons' for easier overriding and testing.
+ *
+ * Other 'reasons' are added to the object as required string components
+ * are defined.
+ */
+var reasons = {
+    NOT_SUBSCRIBED: "You are not subscribed to this bug.",
+    MUTED_SUBSCRIPTION: "You have muted all email from this bug."
+};
+namespace._reasons = reasons;
+
+/* These are components for team participation. */
+var _OF_TEAM = 'of the team {team}. That team is ';
+var _OF_TEAMS = 'of the teams {teams}.  Those teams are ';
+var _BECAUSE_TEAM_IS = _BECAUSE_YOU_ARE + 'a member ' + _OF_TEAM;
+var _ADMIN_BECAUSE_TEAM_IS = (
+    _BECAUSE_YOU_ARE + 'a member and administrator ' + _OF_TEAM);
+var _BECAUSE_TEAMS_ARE = _BECAUSE_YOU_ARE + 'a member ' + _OF_TEAMS;
+var _ADMIN_BECAUSE_TEAMS_ARE = (
+        _BECAUSE_YOU_ARE + 'a member and administrator ' + _OF_TEAMS);
+
+/* These are the assignment variations. */
+var _ASSIGNED = 'assigned to work on it.';
+/* These are the actual strings to use. */
+Y.mix(reasons, {
+    YOU_ASSIGNED: _BECAUSE_YOU_ARE + _ASSIGNED,
+    TEAM_ASSIGNED: _BECAUSE_TEAM_IS + _ASSIGNED,
+    ADMIN_TEAM_ASSIGNED: _ADMIN_BECAUSE_TEAM_IS + _ASSIGNED,
+    TEAMS_ASSIGNED: _BECAUSE_TEAMS_ARE + _ASSIGNED,
+    ADMIN_TEAMS_ASSIGNED: _ADMIN_BECAUSE_TEAMS_ARE + _ASSIGNED
+});
+
+/* These are the direct subscription variations. */
+var _SUBSCRIBED = 'directly subscribed to it.';
+var _MAY_HAVE_BEEN_CREATED = ' This subscription may have been created ';
+var _YOU_SUBSCRIBED = _BECAUSE_YOU_ARE + _SUBSCRIBED;
+
+/* Now these are the actual options we use. */
+Y.mix(reasons, {
+    YOU_SUBSCRIBED: _YOU_SUBSCRIBED,
+    YOU_REPORTED: (_YOU_SUBSCRIBED + _MAY_HAVE_BEEN_CREATED +
+                    'when you reported the bug.'),
+    YOU_SUBSCRIBED_BUG_SUPERVISOR: (
+        _YOU_SUBSCRIBED + _MAY_HAVE_BEEN_CREATED +
+            'because the bug was private and you are a bug supervisor.'),
+    YOU_SUBSCRIBED_SECURITY_CONTACT: (
+        _YOU_SUBSCRIBED + _MAY_HAVE_BEEN_CREATED +
+            'because the bug was security related and you are ' +
+            'a security contact.'),
+    TEAM_SUBSCRIBED: _BECAUSE_TEAM_IS + _SUBSCRIBED,
+    ADMIN_TEAM_SUBSCRIBED: _ADMIN_BECAUSE_TEAM_IS + _SUBSCRIBED,
+    TEAMS_SUBSCRIBED: _BECAUSE_TEAMS_ARE + _SUBSCRIBED,
+    ADMIN_TEAMS_SUBSCRIBED: _ADMIN_BECAUSE_TEAMS_ARE + _SUBSCRIBED
+});
+
+/* These are the duplicate bug variations. */
+var _SUBSCRIBED_TO_DUPLICATE = (
+    'a direct subscriber to bug {duplicate_bug}, which is marked as a ' +
+        'duplicate of this bug, {bug_id}');
+var _SUBSCRIBED_TO_DUPLICATES = (
+    'a direct subscriber to bugs {duplicate_bugs}, which are marked as ' +
+        'duplicates of this bug, {bug_id}');
+/* These are the actual strings to use. */
+Y.mix(reasons, {
+    YOU_SUBSCRIBED_TO_DUPLICATE: _BECAUSE_YOU_ARE + _SUBSCRIBED_TO_DUPLICATE,
+    YOU_SUBSCRIBED_TO_DUPLICATES: (
+        _BECAUSE_YOU_ARE + _SUBSCRIBED_TO_DUPLICATES),
+    TEAM_SUBSCRIBED_TO_DUPLICATE: _BECAUSE_TEAM_IS + _SUBSCRIBED_TO_DUPLICATE,
+    TEAM_SUBSCRIBED_TO_DUPLICATES: (
+        _BECAUSE_TEAM_IS + _SUBSCRIBED_TO_DUPLICATES),
+    ADMIN_TEAM_SUBSCRIBED_TO_DUPLICATE: (
+        _ADMIN_BECAUSE_TEAM_IS + _SUBSCRIBED_TO_DUPLICATE),
+    ADMIN_TEAM_SUBSCRIBED_TO_DUPLICATES: (
+        _ADMIN_BECAUSE_TEAM_IS + _SUBSCRIBED_TO_DUPLICATES),
+});
+
+/* These are the owner variations. */
+var _OWNER = (
+    "the owner of the {pillar_type} " +
+        "{pillar}, which has no bug supervisor.");
+/* These are the actual strings to use. */
+Y.mix(reasons, {
+    YOU_OWNER: _BECAUSE_YOU_ARE + _OWNER,
+    TEAM_OWNER: _BECAUSE_TEAM_IS + _OWNER,
+    ADMIN_TEAM_OWNER: _ADMIN_BECAUSE_TEAM_IS + _OWNER,
+});
+
+
+/**
+ * Return appropriate object based on the number.
+ *
+ * @method choose_by_number.
+ * @param {Integer} number Number used in the string.
+ * @param {Object} singular Object to return when number == 1.
+ * @param {Object} plural Object to return when number != 1.
+ */
+function choose_by_number(number, singular, plural) {
+    if (number == 1) {
+        return singular;
+    } else {
+        return plural;
+    }
+}
+namespace._choose_by_number = choose_by_number;
+
+/**
+ * Replaces textual references in `info` with actual objects from `cache`.
+ *
+ * This assumes that object references are specified with strings
+ * starting with 'subscription-cache-reference', and are direct keys
+ * for objects in `cache`.
+ *
+ * @param {Object} info Object to recursively look for references through.
+ * @param {Object} cache Cache containing the objects indexed by their
+ *                       references.
+ */
+function replace_textual_references(info, cache) {
+    for (var key in info) {
+        switch (typeof info[key]){
+        case "object":
+            replace_textual_references(info[key], cache);
+            break;
+        case "string":
+            var ref_string = "subscription-cache-reference-";
+            if (info[key].substring(0, ref_string.length) == ref_string) {
+                info[key] = cache[info[key]];
+            }
+            break;
+        default: break;
+        }
+    }
+}
+namespace._replace_textual_references = replace_textual_references;
+
+/**
+ * ObjectLink class to unify link elements for better consistency.
+ * Needed because some objects expose `title`, others expose `display_name`.
+ */
+ObjectLink = function(self, title, url) {
+    return {
+        self: self,
+        title: title,
+        url: url
+    };
+}
+
+/**
+ * Convert a context object to a { title, url } object for use in web pages.
+ * Uses `display_name` and `web_link` attributes.
+ * Additionally, accepts a string as well and returns it unmodified.
+ */
+function get_link_data(context) {
+    // For testing, we take strings as well.
+    if (typeof(context) == 'string') {
+        return context;
+    } else {
+        return ObjectLink(context, context.display_name, context.web_link);
+    }
+}
+
+/**
+ * Convert a bug object to a { title, url } object for use in web pages.
+ * Uses `id` and `web_link` attributes.
+ * Additionally, accepts a string as well and returns it unmodified.
+ */
+function get_bug_link_data(bug) {
+    // For testing, we take strings as well.
+    if (typeof(bug) == 'string') {
+        return bug;
+    } else {
+        return ObjectLink(bug, 'bug #' + bug.id.toString(), bug.web_link);
+    }
+}
+
+/**
+ * Gather all team subscriptions and sort them by the role: member/admin.
+ * Returns up to 2 different subscription records, one for all teams
+ * a person is a member of, and another for all teams a person is
+ * an admin for.
+ * With one team in a subscription, variable `team` is set, and with more
+ * than one, variable `teams` is set containing all the teams.
+ */
+function gather_subscriptions_by_role(
+    category, team_singular, team_plural,
+    admin_team_singular, admin_team_plural) {
+    var subscriptions = [];
+    if (category.as_team_member.length > 0) {
+        var teams = [];
+        for (var index in category.as_team_member) {
+            var team_subscription = category.as_team_member[index];
+            teams.push(get_link_data(team_subscription.principal));
+        }
+        var sub = choose_by_number(
+            category.as_team_member.length,
+            { reason: team_singular,
+              vars: {
+                  team: teams[0] } },
+            { reason: team_plural,
+              vars: {
+                  teams: teams } });
+        subscriptions.push(sub);
+    }
+
+    if (category.as_team_admin.length > 0) {
+        var teams = [];
+        for (var index in category.as_team_admin) {
+            var team_subscription = category.as_team_admin[index];
+            teams.push(get_link_data(team_subscription.principal));
+        }
+        var sub = choose_by_number(
+            category.as_team_admin.length,
+            { reason: admin_team_singular,
+              vars: {
+                  team: teams[0] } },
+            { reason: admin_team_plural,
+              vars: {
+                  teams: teams } });
+        subscriptions.push(sub);
+    }
+
+    return subscriptions;
+}
+
+/**
+ * Gather subscription information for assignee.
+ */
+function gather_subscriptions_as_assignee(category) {
+    var subscriptions = [];
+    var reasons = namespace._reasons;
+
+    if (category.personal.length > 0) {
+        subscriptions.push(
+            { reason: reasons.YOU_ASSIGNED,
+              vars: {} });
+    }
+
+    // We add all the team assignments grouped by roles in the team.
+    return subscriptions.concat(
+        gather_subscriptions_by_role(
+            category, reasons.TEAM_ASSIGNED, reasons.TEAMS_ASSIGNED,
+            reasons.ADMIN_TEAM_ASSIGNED, reasons.ADMIN_TEAMS_ASSIGNED));
+}
+namespace._gather_subscriptions_as_assignee =
+        gather_subscriptions_as_assignee;
+
+/**
+ * Gather subscription information for implicit bug supervisor.
+ */
+function gather_subscriptions_as_supervisor(category) {
+    var subscriptions = [];
+    var reasons = namespace._reasons;
+
+    for (var index in category.personal) {
+        var subscription = category.personal[index];
+        subscriptions.push({
+            reason: reasons.YOU_OWNER,
+            vars: {
+                pillar: get_link_data(subscription.pillar)
+            }
+        });
+    }
+
+    for (var index in category.as_team_member) {
+        var team_subscription = category.as_team_member[index];
+        subscriptions.push({
+            reason: reasons.TEAM_OWNER,
+            vars: {
+                team: get_link_data(team_subscription.principal),
+                pillar: get_link_data(team_subscription.pillar)
+            }
+        });
+    }
+
+    for (var index in category.as_team_admin) {
+        var team_subscription = category.as_team_admin[index];
+        subscriptions.push({
+            reason: reasons.ADMIN_TEAM_OWNER,
+            vars: {
+                team: get_link_data(team_subscription.principal),
+                pillar: get_link_data(team_subscription.pillar)
+            }
+        });
+    }
+
+    return subscriptions;
+}
+namespace._gather_subscriptions_as_supervisor =
+        gather_subscriptions_as_supervisor;
+
+function gather_dupe_subscriptions_by_team(team_subscriptions,
+                                           singular, plural) {
+    var subscriptions = [];
+
+    // Collated list of { team: ..., bugs: []} records.
+    var dupes_by_teams = [];
+    for (var index in team_subscriptions) {
+        var subscription = team_subscriptions[index];
+        // Find the existing team reference.
+        var added_bug = false;
+        for (var team_dupes_idx in dupes_by_teams) {
+            var team_dupes = dupes_by_teams[team_dupes_idx];
+            if (team_dupes.team == subscription.principal) {
+                team_dupes.bugs.push(get_bug_link_data(subscription.bug));
+                added_bug = true;
+                break;
+            }
+        }
+        if (!added_bug) {
+            dupes_by_teams.push({
+                team: subscription.principal,
+                bugs: [get_bug_link_data(subscription.bug)]
+            });
+        }
+    }
+    for (var team_dupes_idx in dupes_by_teams) {
+        var team_dupes = dupes_by_teams[team_dupes_idx];
+        var sub = choose_by_number(
+            team_dupes.bugs.length,
+            { reason: singular,
+              vars: { duplicate_bug: team_dupes.bugs[0],
+                      team: get_link_data(team_dupes.team) }},
+            { reason: plural,
+              vars: { duplicate_bugs: team_dupes.bugs,
+                      team: get_link_data(team_dupes.team) }});
+        subscriptions.push(sub);
+    }
+    return subscriptions;
+}
+
+/**
+ * Gather subscription information from duplicate bug subscriptions.
+ */
+function gather_subscriptions_from_duplicates(category) {
+    var subscriptions = [];
+    var reasons = namespace._reasons;
+
+    if (category.personal.length > 0) {
+        var dupes = [];
+        for (var index in category.personal) {
+            var subscription = category.personal[index];
+            dupes.push(
+                get_bug_link_data(subscription.bug));
+        }
+        var sub = choose_by_number(
+            dupes.length,
+            { reason: reasons.YOU_SUBSCRIBED_TO_DUPLICATE,
+              vars: { duplicate_bug: dupes[0] }},
+            { reason: reasons.YOU_SUBSCRIBED_TO_DUPLICATES,
+              vars: { duplicate_bugs: dupes }});
+        subscriptions.push(sub);
+    }
+
+    // Get subscriptions as team member, grouped by teams.
+    subscriptions = subscriptions.concat(
+        gather_dupe_subscriptions_by_team(
+            category.as_team_member,
+            reasons.TEAM_SUBSCRIBED_TO_DUPLICATE,
+            reasons.TEAM_SUBSCRIBED_TO_DUPLICATES));
+
+    // Get subscriptions as team admin, grouped by teams.
+    subscriptions = subscriptions.concat(
+        gather_dupe_subscriptions_by_team(
+            category.as_team_admin,
+            reasons.ADMIN_TEAM_SUBSCRIBED_TO_DUPLICATE,
+            reasons.ADMIN_TEAM_SUBSCRIBED_TO_DUPLICATES));
+
+    return subscriptions;
+}
+namespace._gather_subscriptions_from_duplicates =
+        gather_subscriptions_from_duplicates;
+
+/**
+ * Gather subscription information from direct team subscriptions.
+ */
+function gather_subscriptions_through_team(category) {
+    var reasons = namespace._reasons;
+    return gather_subscriptions_by_role(
+        category, reasons.TEAM_SUBSCRIBED, reasons.TEAMS_SUBSCRIBED,
+        reasons.ADMIN_TEAM_SUBSCRIBED, reasons.ADMIN_TEAMS_SUBSCRIBED);
+}
+namespace._gather_subscriptions_through_team =
+        gather_subscriptions_through_team;
+
+/**
+ * Gather all non-direct subscriptions into a list.
+ */
+function gather_nondirect_subscriptions(info) {
+    var subscriptions = [];
+
+    return subscriptions
+        .concat(gather_subscriptions_as_assignee(info.as_assignee))
+        .concat(gather_subscriptions_from_duplicates(info.from_duplicate))
+        .concat(gather_subscriptions_through_team(info.direct))
+        .concat(gather_subscriptions_as_supervisor(info.as_owner));
+
+}
+
+function get_subscription_reason(config) {
+    // Allow tests to pass subscription_info directly in.
+    var info = config.subscription_info || LP.cache.bug_subscription_info;
+    console.log(info);
+    replace_textual_references(info, LP.cache);
+
+    var subs = gather_nondirect_subscriptions(info);
+    console.log(subs);
+
+}
+namespace.get_subscription_reason = get_subscription_reason
+
+}, '0.1', {requires: [
+    'dom', 'node', 'substitute'
+]});

=== added file 'lib/lp/bugs/javascript/tests/test_subscription.html'
--- lib/lp/bugs/javascript/tests/test_subscription.html	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_subscription.html	2011-04-12 09:44:39 +0000
@@ -0,0 +1,41 @@
+<html>
+  <head>
+    <title>Bug subscriptions: descriptions and unsubscribe actions</title>
+
+     <!-- YUI 3.0 Setup -->
+    <script type="text/javascript"
+      src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
+    <script type="text/javascript"
+      src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
+    <link rel="stylesheet"
+      href="../../../../canonical/launchpad/javascript/test.css" />
+
+    <script type="text/javascript"
+      src="../../../app/javascript/client.js"></script>
+
+    <!-- The module under test -->
+    <script type="text/javascript"
+      src="../subscription.js"></script>
+
+    <!-- The test suite -->
+    <script type="text/javascript"
+      src="test_subscription.js"></script>
+
+    <!-- Pretty up the sample html -->
+    <style type="text/css">
+      div#sample {margin:15px; width:200px; border:1px solid #999; padding:10px;}
+    </style>
+  </head>
+  <body class="yui3-skin-sam">
+    <!-- Example markup required by test suite -->
+
+    <!-- The test output -->
+    <div id="log"></div>
+  </body>
+</html>

=== added file 'lib/lp/bugs/javascript/tests/test_subscription.js'
--- lib/lp/bugs/javascript/tests/test_subscription.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_subscription.js	2011-04-12 09:44:39 +0000
@@ -0,0 +1,741 @@
+YUI({
+    base: '../../../../canonical/launchpad/icing/yui/',
+    filter: 'raw', combine: false, fetchCSS: false
+    }).use('test', 'console', 'lp.bugs.subscription', function(Y) {
+
+var suite = new Y.Test.Suite("lp.bugs.subscription Tests");
+var module = Y.lp.bugs.subscription;
+
+/**
+ * Test selection of the string by the number.
+ * We expect to receive a plural string for all numbers
+ * not equal to 1, and a singular string otherwise.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Choose object by number',
+
+    test_singular: function() {
+        Y.Assert.areEqual(
+            'SINGULAR',
+            module._choose_by_number(1, 'SINGULAR', 'PLURAL'));
+    },
+
+    test_plural: function() {
+        Y.Assert.areEqual(
+            'PLURAL',
+            module._choose_by_number(5, 'SINGULAR', 'PLURAL'));
+    },
+
+    test_zero: function() {
+        Y.Assert.areEqual(
+            'PLURAL',
+            module._choose_by_number(0, 'SINGULAR', 'PLURAL'));
+    }
+}));
+
+/**
+ * Replacing references to cache objects with actual objects.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Replacing references with real objects',
+
+    test_nothing: function() {
+        // When there are no references, nothing gets replaced.
+        var object = {
+            something: 'nothing'
+        };
+        var cache = {};
+        module._replace_textual_references(object, cache)
+        Y.Assert.areEqual('nothing', object.something);
+    },
+
+    test_simple: function() {
+        // With a simple reference, it gets substituted.
+        var object = {
+            something: 'subscription-cache-reference-1'
+        };
+        var cache = {
+            'subscription-cache-reference-1': 'OK'
+        };
+        module._replace_textual_references(object, cache);
+        Y.Assert.areEqual('OK', object.something);
+    },
+
+    test_multiple: function() {
+        // With multiple references, they all get substituted.0
+        var object = {
+            something: 'subscription-cache-reference-1',
+            other: 'subscription-cache-reference-2'
+        };
+        var cache = {
+            'subscription-cache-reference-1': 'OK 1',
+            'subscription-cache-reference-2': 'OK 2'
+        };
+        module._replace_textual_references(object, cache);
+        Y.Assert.areEqual('OK 1', object.something);
+        Y.Assert.areEqual('OK 2', object.other);
+    },
+
+    test_recursive: function() {
+        // Even references in nested objects get replaced.
+        var object = {
+            nested: {
+                something: 'subscription-cache-reference-1'
+            }
+        };
+        var cache = {
+            'subscription-cache-reference-1': 'OK'
+        };
+        module._replace_textual_references(object, cache);
+        Y.Assert.areEqual('OK', object.nested.something);
+    }
+}));
+
+
+/**
+ * Gather short (just-enough) subscription records for all assignments.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Gather assignment subscription information',
+
+    test_nothing: function() {
+        // When there are no subscriptions as assignee, returns empty list.
+        var mock_category = {
+            count: 0,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        Y.ArrayAssert.itemsAreEqual(
+            [],
+            module._gather_subscriptions_as_assignee(mock_category));
+    },
+
+    test_personal: function() {
+        // When a person is directly the bug assignee, we get that
+        // subscription details returned.
+        var mock_category = {
+            count: 1,
+            personal: [{}],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.YOU_ASSIGNED, subs[0].reason);
+    },
+
+    test_team_member: function() {
+        // When a person is the bug assignee through team membership,
+        // we get that subscription details returned.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{ principal: 'my team'}],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.TEAM_ASSIGNED, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+    },
+
+    test_team_member_multiple: function() {
+        // If a person is a member of multiple teams are assigned to work
+        // on a single bug (eg. on different bug tasks) they get only one
+        // subscription returned.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [{ principal: 'team1'},
+                             { principal: 'team2'}],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.TEAMS_ASSIGNED, subs[0].reason);
+        // And there is a 'teams' variable containing all the team objects.
+        Y.ArrayAssert.itemsAreEqual(['team1', 'team2'],
+                                    subs[0].vars.teams);
+    },
+
+    test_team_admin: function() {
+        // When a person is the bug assignee through team membership,
+        // and a team admin at the same time, that subscription is returned.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'my team' }],
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAM_ASSIGNED, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+    },
+
+    test_team_admin_multiple: function() {
+        // If a person is a member of multiple teams are assigned to work
+        // on a single bug (eg. on different bug tasks) they get only one
+        // subscription returned.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'team1'},
+                             { principal: 'team2'}],
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAMS_ASSIGNED, subs[0].reason);
+        // And there is a 'teams' variable containing all the team objects.
+        Y.ArrayAssert.itemsAreEqual(['team1', 'team2'],
+                                    subs[0].vars.teams);
+    },
+
+    test_combined: function() {
+        // Test that multiple assignments, even if they are in different
+        // categories, work properly.
+        var mock_category = {
+            count: 3,
+            personal: [{}],
+            as_team_member: [{ principal: 'users' }],
+            as_team_admin: [{ principal: 'admins' }],
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(3, subs.length);
+    },
+
+    test_object_links: function() {
+        // Test that team assignments actually provide decent link data.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [
+                { principal: { display_name: 'My team',
+                               web_link: 'http://link' } }],
+            as_team_admin: [],
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual('My team', subs[0].vars.team.title);
+        Y.Assert.areEqual('http://link', subs[0].vars.team.url);
+    },
+}));
+
+/**
+ * Gather short (just-enough) subscription records for bug supervisor.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Gather bug supervisor subscription information',
+
+    test_nothing: function() {
+        // When there are no subscriptions as bug supervisor,
+        // returns empty list.
+        var mock_category = {
+            count: 0,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        Y.ArrayAssert.itemsAreEqual(
+            [],
+            module._gather_subscriptions_as_supervisor(mock_category));
+    },
+
+    test_personal: function() {
+        // Person is the implicit bug supervisor by being the owner
+        // of the project with no bug supervisor.
+        var mock_category = {
+            count: 1,
+            personal: [{pillar: 'project'}],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.YOU_OWNER, subs[0].reason);
+        Y.Assert.areEqual('project', subs[0].vars.pillar);
+    },
+
+    test_personal_multiple: function() {
+        // Person is the implicit bug supervisor by being the owner
+        // of several projects (eg. multiple bug tasks) with no bug
+        // supervisor.
+        var mock_category = {
+            count: 2,
+            personal: [{pillar: 'project'}, {pillar: 'distro'}],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual(2, subs.length);
+    },
+
+    test_team_member: function() {
+        // Person is a member of the team which is the implicit
+        // bug supervisor.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{ principal: 'my team',
+                               pillar: 'project' }],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.TEAM_OWNER, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+        Y.Assert.areEqual('project', subs[0].vars.pillar);
+    },
+
+    test_team_member_multiple: function() {
+        // Person is a member of several teams which are implicit bug
+        // supervisors on multiple bugtasks, we get subscription
+        // records separately.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [{ principal: 'team1',
+                               pillar: 'project' },
+                             { principal: 'team2',
+                               pillar: 'distro' }],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual(2, subs.length);
+    },
+
+    test_team_admin: function() {
+        // Person is an admin of the team which is the implicit
+        // bug supervisor.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'my team',
+                              pillar: 'project' }],
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAM_OWNER, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+        Y.Assert.areEqual('project', subs[0].vars.pillar);
+    },
+
+    test_team_admin_multiple: function() {
+        // Person is an admin of several teams which are implicit bug
+        // supervisors on multiple bugtasks, we get subscription
+        // records separately.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'team1',
+                               pillar: 'project' },
+                             { principal: 'team2',
+                               pillar: 'distro' }]
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual(2, subs.length);
+    },
+
+    test_combined: function() {
+        // Test that multiple implicit bug supervisor roles
+        // are all returned.
+        var mock_category = {
+            count: 3,
+            personal: [{pillar: 'project1'}],
+            as_team_member: [{ principal: 'users', pillar: 'project2' }],
+            as_team_admin: [{ principal: 'admins', pillar: 'distro' }],
+        };
+        var subs = module._gather_subscriptions_as_assignee(mock_category);
+        Y.Assert.areEqual(3, subs.length);
+    },
+
+    test_object_links: function() {
+        // Test that team-as-supervisor actually provide decent link data,
+        // along with pillars as well.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{
+                principal: { display_name: 'My team',
+                             web_link: 'http://link' },
+                pillar: { display_name: 'My project',
+                          web_link: 'http://project/' }
+            }],
+            as_team_admin: [],
+        };
+        var subs = module._gather_subscriptions_as_supervisor(mock_category);
+        Y.Assert.areEqual('My team', subs[0].vars.team.title);
+        Y.Assert.areEqual('http://link', subs[0].vars.team.url);
+
+        Y.Assert.areEqual('My project', subs[0].vars.pillar.title);
+        Y.Assert.areEqual('http://project/', subs[0].vars.pillar.url);
+    },
+}));
+
+/**
+ * Gather short (just-enough) subscription records for dupe bug subscriptions.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Gather subscription information for duplicates',
+
+    test_nothing: function() {
+        // When there are no duplicate subscriptions, returns empty list.
+        var mock_category = {
+            count: 0,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        Y.ArrayAssert.itemsAreEqual(
+            [],
+            module._gather_subscriptions_from_duplicates(mock_category));
+    },
+
+    test_personal: function() {
+        // A person is subscribed to a duplicate bug.
+        var mock_category = {
+            count: 1,
+            personal: [{bug: 'dupe bug'}],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.YOU_SUBSCRIBED_TO_DUPLICATE, subs[0].reason);
+        Y.Assert.areEqual('dupe bug', subs[0].vars.duplicate_bug);
+    },
+
+    test_personal_multiple: function() {
+        // A person is subscribed to multiple duplicate bugs.
+        // They are returned together as one subscription record.
+        var mock_category = {
+            count: 2,
+            personal: [{bug: 'dupe1'}, {bug: 'dupe2'}],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.YOU_SUBSCRIBED_TO_DUPLICATES, subs[0].reason);
+        Y.ArrayAssert.itemsAreEqual(
+            ['dupe1', 'dupe2'], subs[0].vars.duplicate_bugs);
+    },
+
+    test_team_member: function() {
+        // A person is a member of the team subscribed to a duplicate bug.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{ principal: 'my team',
+                               bug: 'dupe' }],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.TEAM_SUBSCRIBED_TO_DUPLICATE, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+        // And a 'duplicate_bug' variable pointing to the dupe.
+        Y.Assert.areEqual('dupe', subs[0].vars.duplicate_bug);
+    },
+
+    test_team_member_multiple_bugs: function() {
+        // A person is a member of the team subscribed to multiple
+        // duplicate bugs.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{
+                principal: 'my team',
+                bug: 'dupe1'
+            }, {
+                principal: 'my team',
+                bug: 'dupe2'
+            }],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.TEAM_SUBSCRIBED_TO_DUPLICATES, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+        // And a 'duplicate_bugs' variable with the list of dupes.
+        Y.ArrayAssert.itemsAreEqual(
+            ['dupe1', 'dupe2'], subs[0].vars.duplicate_bugs);
+    },
+
+    test_team_member_multiple: function() {
+        // A person is a member of several teams subscribed to
+        // duplicate bugs.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [{ principal: 'team1',
+                               bug: 'dupe1' },
+                             { principal: 'team2',
+                               bug: 'dupe1' }],
+            as_team_admin: []
+        };
+
+        // Result is two separate subscription records.
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(2, subs.length);
+    },
+
+    test_team_admin: function() {
+        // A person is an admin of the team subscribed to a duplicate bug.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'my team',
+                               bug: 'dupe' }]
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAM_SUBSCRIBED_TO_DUPLICATE,
+            subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+        // And a 'duplicate_bug' variable pointing to the dupe.
+        Y.Assert.areEqual('dupe', subs[0].vars.duplicate_bug);
+    },
+
+    test_team_admin_multiple_bugs: function() {
+        // A person is an admin of the team subscribed to multiple
+        // duplicate bugs.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{
+                principal: 'my team',
+                bug: 'dupe1'
+            }, {
+                principal: 'my team',
+                bug: 'dupe2'
+            }]
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAM_SUBSCRIBED_TO_DUPLICATES,
+            subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+        // And a 'duplicate_bugs' variable with the list of dupes.
+        Y.ArrayAssert.itemsAreEqual(
+            ['dupe1', 'dupe2'], subs[0].vars.duplicate_bugs);
+    },
+
+    test_team_admin_multiple: function() {
+        // A person is an admin of several teams subscribed to
+        // duplicate bugs.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'team1',
+                               bug: 'dupe1' },
+                             { principal: 'team2',
+                               bug: 'dupe1' }],
+        };
+
+        // Result is two separate subscription records.
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual(2, subs.length);
+    },
+
+    test_object_links: function() {
+        // Test that team dupe subscriptions actually provide decent
+        // link data, including duplicate bugs link data.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{
+                principal: { display_name: 'My team',
+                             web_link: 'http://link' },
+                bug: { id: 1,
+                       web_link: 'http://launchpad/bug/1' }
+            }],
+            as_team_admin: [],
+        };
+        var subs = module._gather_subscriptions_from_duplicates(
+            mock_category);
+        Y.Assert.areEqual('My team', subs[0].vars.team.title);
+        Y.Assert.areEqual('http://link', subs[0].vars.team.url);
+
+        Y.Assert.areEqual('bug #1', subs[0].vars.duplicate_bug.title);
+        Y.Assert.areEqual(
+            'http://launchpad/bug/1', subs[0].vars.duplicate_bug.url);
+    },
+}));
+
+/**
+ * Gather short (just-enough) subscription records for direct
+ * team subscriptions.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Gather team subscription information',
+
+    test_nothing: function() {
+        // When there are no subscriptions through team, returns empty list.
+        var mock_category = {
+            count: 0,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        Y.ArrayAssert.itemsAreEqual(
+            [],
+            module._gather_subscriptions_through_team(mock_category));
+    },
+
+    test_personal: function() {
+        // A personal subscription is not considered a team subscription.
+        var mock_category = {
+            count: 1,
+            personal: [{}],
+            as_team_member: [],
+            as_team_admin: []
+        };
+        Y.ArrayAssert.itemsAreEqual(
+            [],
+            module._gather_subscriptions_through_team(mock_category));
+    },
+
+    test_team_member: function() {
+        // Person is a member of the team subscribed to the bug.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [{ principal: 'my team'}],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_through_team(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.TEAM_SUBSCRIBED, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+    },
+
+    test_team_member_multiple: function() {
+        // Person is a member of several teams subscribed to the bug.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [{ principal: 'team1'},
+                             { principal: 'team2'}],
+            as_team_admin: []
+        };
+        var subs = module._gather_subscriptions_through_team(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(module._reasons.TEAMS_SUBSCRIBED, subs[0].reason);
+        // And there is a 'teams' variable containing all the team objects.
+        Y.ArrayAssert.itemsAreEqual(['team1', 'team2'],
+                                    subs[0].vars.teams);
+    },
+
+    test_team_admin: function() {
+        // Person is an admin of the team subscribed to the bug.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'my team' }],
+        };
+        var subs = module._gather_subscriptions_through_team(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAM_SUBSCRIBED, subs[0].reason);
+        // And there is a 'team' variable containing the team object.
+        Y.Assert.areEqual('my team', subs[0].vars.team);
+    },
+
+    test_team_admin_multiple: function() {
+        // Person is an admin of the several teams subscribed to the bug.
+        var mock_category = {
+            count: 2,
+            personal: [],
+            as_team_member: [],
+            as_team_admin: [{ principal: 'team1'},
+                             { principal: 'team2'}],
+        };
+        var subs = module._gather_subscriptions_through_team(mock_category);
+        Y.Assert.areEqual(1, subs.length);
+        Y.Assert.areEqual(
+            module._reasons.ADMIN_TEAMS_SUBSCRIBED, subs[0].reason);
+        // And there is a 'teams' variable containing all the team objects.
+        Y.ArrayAssert.itemsAreEqual(['team1', 'team2'],
+                                    subs[0].vars.teams);
+    },
+
+    test_combined: function() {
+        // Test that multiple subscriptions, even if they are in different
+        // categories, work properly, and that personal subscriptions are
+        // still ignored.
+        var mock_category = {
+            count: 3,
+            personal: [{}],
+            as_team_member: [{ principal: 'users' }],
+            as_team_admin: [{ principal: 'admins' }],
+        };
+        var subs = module._gather_subscriptions_through_team(mock_category);
+        Y.Assert.areEqual(2, subs.length);
+    },
+
+    test_object_links: function() {
+        // Test that team subscriptions actually provide decent link data.
+        var mock_category = {
+            count: 1,
+            personal: [],
+            as_team_member: [
+                { principal: { display_name: 'My team',
+                               web_link: 'http://link' } }],
+            as_team_admin: [],
+        };
+        var subs = module._gather_subscriptions_through_team(mock_category);
+        Y.Assert.areEqual('My team', subs[0].vars.team.title);
+        Y.Assert.areEqual('http://link', subs[0].vars.team.url);
+    },
+}));
+
+var handle_complete = function(data) {
+    status_node = Y.Node.create(
+        '<p id="complete">Test status: complete</p>');
+    Y.one('body').appendChild(status_node);
+    };
+Y.Test.Runner.on('complete', handle_complete);
+Y.Test.Runner.add(suite);
+
+var console = new Y.Console({newestOnTop: false});
+console.render('#log');
+
+Y.on('domready', function() {
+    Y.Test.Runner.run();
+});
+});
+

=== modified file 'lib/lp/bugs/templates/bug-subscription-list.pt'
--- lib/lp/bugs/templates/bug-subscription-list.pt	2011-04-07 21:31:29 +0000
+++ lib/lp/bugs/templates/bug-subscription-list.pt	2011-04-12 09:44:39 +0000
@@ -15,11 +15,15 @@
     <script type="text/javascript"
         tal:condition="
           request/features/malone.advanced-structural-subscriptions.enabled">
-      LPS.use('lp.registry.structural_subscription', function(Y) {
-          var module = Y.lp.registry.structural_subscription;
+      LPS.use('lp.registry.structural_subscription', 'lp.bugs.subscription',
+              function(Y) {
+          var ss_module = Y.lp.registry.structural_subscription;
+          var info_module = Y.lp.bugs.subscription;
           Y.on('domready', function() {
-              module.setup_bug_subscriptions(
-                  {content_box: "#structural-subscription-content-box"})
+              ss_module.setup_bug_subscriptions(
+                  {content_box: "#structural-subscription-content-box"});
+              console.log(info_module.get_subscription_reason(
+                  {description_box: "#description"}));
           });
       });
     </script>